kempo-ui 0.1.3 → 0.1.5

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.
@@ -5,4 +5,7 @@ import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-al
5
5
  :host([hidden]) {
6
6
  display: none !important;
7
7
  }
8
+ :host([kb-focus]) ::slotted(a) {
9
+ background: rgba(128,128,128,.25);
10
+ }
8
11
  `}customElements.define("k-filter-item",FilterItem);
@@ -1,4 +1,4 @@
1
- import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-all.min.js";export default class FilterList extends ShadowComponent{render(){return html`<slot></slot>`}filter(t){const e=t.toLowerCase().split(/\s+/).filter(t=>t.length>0);this.querySelectorAll("k-filter-item").forEach(t=>{const s=(t.getAttribute("filter-keywords")||"").toLowerCase();t.hidden=e.length>0&&!e.every(t=>s.includes(t))})}static styles=css`
1
+ import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-all.min.js";export default class FilterList extends ShadowComponent{#e=-1;render(){return html`<slot></slot>`}handleKeydown=e=>{const t=this.#t();t.length&&("ArrowDown"===e.key?(e.preventDefault(),this.#e=Math.min(this.#e+1,t.length-1),this.#s(t)):"ArrowUp"===e.key?(e.preventDefault(),this.#e=Math.max(this.#e-1,0),this.#s(t)):"Enter"===e.key&&this.#e>=0&&this.#e<t.length&&(e.preventDefault(),t[this.#e].querySelector("a")?.click()))};clearFocus=()=>{this.#e=-1,this.querySelectorAll("k-filter-item[kb-focus]").forEach(e=>e.removeAttribute("kb-focus"))};filter(e){const t=e.toLowerCase().split(/\s+/).filter(e=>e.length>0);this.querySelectorAll("k-filter-item").forEach(e=>{const s=(e.getAttribute("filter-keywords")||"").toLowerCase();e.hidden=t.length>0&&!t.every(e=>s.includes(e))}),this.clearFocus()}#t=()=>[...this.querySelectorAll("k-filter-item:not([hidden])")];#s=e=>{this.querySelectorAll("k-filter-item[kb-focus]").forEach(e=>e.removeAttribute("kb-focus")),e[this.#e]?.setAttribute("kb-focus",""),e[this.#e]?.scrollIntoView({block:"nearest"})};static styles=css`
2
2
  :host {
3
3
  display: block;
4
4
  }
@@ -0,0 +1,8 @@
1
+ import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-all.min.js";import theme from"../utils/theme.js";export default class ThemeSelect extends ShadowComponent{static properties={currentTheme:{type:String,reflect:!0,attribute:"current-theme"}};constructor(){super(),this.currentTheme=theme.get()}handleChange=e=>{theme.set(e.target.value)};connectedCallback(){super.connectedCallback(),this.unsubscribe=theme.subscribe(e=>{this.currentTheme=e})}disconnectedCallback(){super.disconnectedCallback(),this.unsubscribe&&this.unsubscribe()}render(){const e=this.childNodes.length>0;return html`
2
+ ${e?html`<label><slot></slot></label>`:""}
3
+ <select @change=${this.handleChange} .value=${this.currentTheme}>
4
+ <option value="light">Light</option>
5
+ <option value="dark">Dark</option>
6
+ <option value="auto">System Default</option>
7
+ </select>
8
+ `}static setTheme(e){theme.set(e)}static getCurrentTheme(){return theme.get()}static getCalculatedCurrentTheme(){return theme.getCalculated()}}customElements.define("k-theme-select",ThemeSelect);
@@ -0,0 +1,124 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ThemeSelect - Components - Kempo Docs - A Web Components Solution</title>
7
+ <link rel="icon" type="image/png" sizes="48x48" href="../media/icon48.png">
8
+ <link rel="manifest" href="../manifest.json" />
9
+ <link rel="stylesheet" href="../kempo-vars.css" />
10
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/kempo-css@1.3.11/dist/kempo.min.css" />
11
+ <link rel="stylesheet" href="../kempo-hljs.css" />
12
+ <link rel="stylesheet" href="../styles.css" />
13
+ <script>window.litDisableBundleWarning = true;</script>
14
+ </head>
15
+ <body>
16
+ <k-import src="../nav-1.inc.html"></k-import>
17
+ <h1 class="ta-center">ThemeSelect</h1>
18
+ <main>
19
+ <k-accordion persistent-id="toc" class="b r mb">
20
+ <k-accordion-header for-panel="toc-panel">Table of Contents</k-accordion-header>
21
+ <k-accordion-panel name="toc-panel">
22
+ <div class="m pl">
23
+ <h6>Examples</h6>
24
+ <a href="#basicUsage">Basic Usage</a><br />
25
+ <a href="#withLabel">With Label</a><br />
26
+
27
+ <h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
28
+ <a href="#constructor">Constructor</a><br />
29
+ <a href="#requirements">Requirements</a><br />
30
+ <a href="#properties">Properties</a><br />
31
+ <a href="#slots">Slots</a><br />
32
+ <a href="#staticMethods">Static Methods</a><br />
33
+ </div>
34
+ </k-accordion-panel>
35
+ </k-accordion>
36
+
37
+ <h3>Description</h3>
38
+ <p>The <code>ThemeSelect</code> component provides a dropdown select for users to choose between Light, Dark, and System Default themes. It extends the <a href="./shadow-component.html">ShadowComponent</a> class and uses the <a href="../utils/theme.html">theme utility</a> internally for state management, persistence, and synchronization.</p>
39
+ <p>If child content is provided, it renders as a label above the select. If no children are provided, no label is rendered.</p>
40
+ <p><strong>Note:</strong> You can manage theme state without this component by directly importing the <a href="../utils/theme.html">theme utility</a>.</p>
41
+
42
+ <h3 id="basicUsage"><a href="#basicUsage" class="no-link">Basic Usage</a></h3>
43
+ <p>Use the <code>ThemeSelect</code> component without any children to render just the select dropdown.</p>
44
+
45
+ <div class="row -mx mb">
46
+ <div class="col d-span-6 m-span-12 px">
47
+ <k-card label="HTML">
48
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-theme-select</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">k-theme-select</span>&gt;</span></code></pre>
49
+ </k-card>
50
+ </div>
51
+ <div class="col d-span-6 m-span-12 px">
52
+ <k-card label="Output">
53
+ <k-theme-select></k-theme-select>
54
+ </k-card>
55
+ </div>
56
+ </div>
57
+
58
+ <h3 id="withLabel"><a href="#withLabel" class="no-link">With Label</a></h3>
59
+ <p>Provide child content to render a label above the select.</p>
60
+
61
+ <div class="row -mx mb">
62
+ <div class="col d-span-6 m-span-12 px">
63
+ <k-card label="HTML">
64
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-theme-select</span>&gt;</span>Theme<span class="hljs-tag">&lt;/<span class="hljs-name">k-theme-select</span>&gt;</span></code></pre>
65
+ </k-card>
66
+ </div>
67
+ <div class="col d-span-6 m-span-12 px">
68
+ <k-card label="Output">
69
+ <k-theme-select>Theme</k-theme-select>
70
+ </k-card>
71
+ </div>
72
+ </div>
73
+
74
+ <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
75
+
76
+ <h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
77
+ <h6>Extends <a href="./shadow-component.html">ShadowComponent</a></h6>
78
+ <h5>
79
+ <code>new ThemeSelect()</code>
80
+ </h5>
81
+
82
+ <h3 id="requirements"><a href="#requirements" class="no-link">Requirements</a></h3>
83
+ <ul>
84
+ <li><a href="./shadow-component.html">ShadowComponent</a></li>
85
+ <li><a href="../utils/theme.html">theme</a> utility</li>
86
+ </ul>
87
+
88
+ <h3 id="properties"><a href="#properties" class="no-link">Properties</a></h3>
89
+ <h5><code>currentTheme<i>: string</i></code></h5>
90
+ <p>Gets or sets the current theme. Possible values are <code>"auto"</code>, <code>"light"</code>, or <code>"dark"</code>. This property is reactive and will trigger a re-render when changed. Syncs to <code>current-theme</code> attribute.</p>
91
+
92
+ <h3 id="slots"><a href="#slots" class="no-link">Slots</a></h3>
93
+ <h5><code>default</code></h5>
94
+ <p>The default slot renders as a label above the select. If no content is provided, no label is rendered.</p>
95
+
96
+ <h3 id="staticMethods"><a href="#staticMethods" class="no-link">Static Methods</a></h3>
97
+ <p><strong>Note:</strong> These static methods are convenience wrappers around the <a href="../utils/theme.html">theme utility</a>. For direct theme management without the component, use the theme utility directly.</p>
98
+ <h5><code>static setTheme(theme)<i>: void</i></code></h5>
99
+ <p>Sets the specified theme. Internally calls the <a href="../utils/theme.html">theme utility</a> which handles persistence and DOM updates.</p>
100
+ <ul>
101
+ <li><strong>theme</strong> <code>string</code> - The theme to set. Must be one of: <code>"auto"</code>, <code>"light"</code>, or <code>"dark"</code></li>
102
+ </ul>
103
+ <pre><code class="hljs javascript"><span class="hljs-title class_">ThemeSelect</span>.<span class="hljs-title function_">setTheme</span>(<span class="hljs-string">&#x27;dark&#x27;</span>);</code></pre>
104
+
105
+ <h5><code>static getCurrentTheme()<i>: string</i></code></h5>
106
+ <p>Gets the current theme. Returns <code>"auto"</code> if no theme is set. Internally calls the <a href="../utils/theme.html">theme utility</a>.</p>
107
+ <pre><code class="hljs javascript"><span class="hljs-keyword">const</span> currentTheme = <span class="hljs-title class_">ThemeSelect</span>.<span class="hljs-title function_">getCurrentTheme</span>();<br><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(currentTheme);</code></pre>
108
+
109
+ <h5><code>static getCalculatedCurrentTheme()<i>: string</i></code></h5>
110
+ <p>Returns the effective theme, always <code>"dark"</code> or <code>"light"</code>. If the current theme is <code>"auto"</code>, this method uses the user's system preference (<code>prefers-color-scheme</code> media query) to determine the theme. Internally calls the <a href="../utils/theme.html">theme utility</a>.</p>
111
+ <pre><code class="hljs javascript"><span class="hljs-keyword">const</span> effectiveTheme = <span class="hljs-title class_">ThemeSelect</span>.<span class="hljs-title function_">getCalculatedCurrentTheme</span>();<br><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(effectiveTheme);</code></pre>
112
+
113
+ <h3>Related</h3>
114
+ <p>See the <a href="../utils/theme.html">theme utility</a> documentation for programmatic theme management without using this component.</p>
115
+ <p>See the <a href="./theme-switcher.html">ThemeSwitcher</a> component for a button-based toggle alternative.</p>
116
+
117
+ </main>
118
+ <div style="height:33vh"></div>
119
+ <script type="module" src="../src/components/Import.js"></script>
120
+ <script type="module" src="../src/components/ThemeSelect.js"></script>
121
+ <script type="module" src="../src/components/Accordion.js"></script>
122
+ <script type="module" src="../src/components/Card.js"></script>
123
+ </body>
124
+ </html>
package/docs/index.html CHANGED
@@ -185,6 +185,12 @@
185
185
  <p class="tc-muted">Notification messages that appear temporarily at screen corners.</p>
186
186
  </a>
187
187
  </div>
188
+ <div class="span-12 t-span-6 d-span-4 px">
189
+ <a href="./components/theme-select.html" class="card mb no-link d-b">
190
+ <h3 class="tc-primary">Theme Select</h3>
191
+ <p class="tc-muted">Dropdown select for choosing between Light, Dark, and System Default themes.</p>
192
+ </a>
193
+ </div>
188
194
  <div class="span-12 t-span-6 d-span-4 px">
189
195
  <a href="./components/theme-switcher.html" class="card mb no-link d-b">
190
196
  <h3 class="tc-primary">Theme Switcher</h3>
@@ -55,6 +55,7 @@
55
55
  <k-filter-item filter-keywords="tabs tab panel components"><a href="../components/tabs.html">Tabs<br><small>Component</small></a></k-filter-item>
56
56
  <k-filter-item filter-keywords="tags tag input components"><a href="../components/tags.html">Tags<br><small>Component</small></a></k-filter-item>
57
57
  <k-filter-item filter-keywords="toast notification alert components"><a href="../components/toast.html">Toast<br><small>Component</small></a></k-filter-item>
58
+ <k-filter-item filter-keywords="theme select dropdown dark light components"><a href="../components/theme-select.html">Theme Select<br><small>Component</small></a></k-filter-item>
58
59
  <k-filter-item filter-keywords="theme switcher dark light components"><a href="../components/theme-switcher.html">Theme Switcher<br><small>Component</small></a></k-filter-item>
59
60
  <k-filter-item filter-keywords="timestamp date time components"><a href="../components/timestamp.html">Timestamp<br><small>Component</small></a></k-filter-item>
60
61
  <k-filter-item filter-keywords="toggle switch checkbox components"><a href="../components/toggle.html">Toggle<br><small>Component</small></a></k-filter-item>
@@ -115,6 +116,7 @@
115
116
  <a href="../components/tabs.html">Tabs</a>
116
117
  <a href="../components/tags.html">Tags</a>
117
118
  <a href="../components/toast.html">Toast</a>
119
+ <a href="../components/theme-select.html">Theme Select</a>
118
120
  <a href="../components/theme-switcher.html">Theme Switcher</a>
119
121
  <a href="../components/timestamp.html">Timestamp</a>
120
122
  <a href="../components/toggle.html">Toggle</a>
package/docs/nav.inc.html CHANGED
@@ -55,6 +55,7 @@
55
55
  <k-filter-item filter-keywords="tabs tab panel components"><a href="./components/tabs.html">Tabs<br><small>Component</small></a></k-filter-item>
56
56
  <k-filter-item filter-keywords="tags tag input components"><a href="./components/tags.html">Tags<br><small>Component</small></a></k-filter-item>
57
57
  <k-filter-item filter-keywords="toast notification alert components"><a href="./components/toast.html">Toast<br><small>Component</small></a></k-filter-item>
58
+ <k-filter-item filter-keywords="theme select dropdown dark light components"><a href="./components/theme-select.html">Theme Select<br><small>Component</small></a></k-filter-item>
58
59
  <k-filter-item filter-keywords="theme switcher dark light components"><a href="./components/theme-switcher.html">Theme Switcher<br><small>Component</small></a></k-filter-item>
59
60
  <k-filter-item filter-keywords="timestamp date time components"><a href="./components/timestamp.html">Timestamp<br><small>Component</small></a></k-filter-item>
60
61
  <k-filter-item filter-keywords="toggle switch checkbox components"><a href="./components/toggle.html">Toggle<br><small>Component</small></a></k-filter-item>
@@ -114,6 +115,7 @@
114
115
  <a href="./components/tabs.html">Tabs</a>
115
116
  <a href="./components/tags.html">Tags</a>
116
117
  <a href="./components/toast.html">Toast</a>
118
+ <a href="./components/theme-select.html">Theme Select</a>
117
119
  <a href="./components/theme-switcher.html">Theme Switcher</a>
118
120
  <a href="./components/timestamp.html">Timestamp</a>
119
121
  <a href="./components/toggle.html">Toggle</a>
package/docs/nav.inc.js CHANGED
@@ -34,6 +34,7 @@ const openSearch = async () => {
34
34
 
35
35
  const closeSearch = () => {
36
36
  searchDropdown.hidden = true;
37
+ navSearchList?.clearFocus();
37
38
  };
38
39
 
39
40
  searchInput.addEventListener('focus', openSearch);
@@ -47,6 +48,8 @@ searchInput.addEventListener('keydown', e => {
47
48
  searchInput.value = '';
48
49
  searchInput.blur();
49
50
  closeSearch();
51
+ } else if(e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
52
+ navSearchList?.handleKeydown(e);
50
53
  }
51
54
  });
52
55
 
@@ -5,4 +5,7 @@ import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-al
5
5
  :host([hidden]) {
6
6
  display: none !important;
7
7
  }
8
+ :host([kb-focus]) ::slotted(a) {
9
+ background: rgba(128,128,128,.25);
10
+ }
8
11
  `}customElements.define("k-filter-item",FilterItem);
@@ -1,4 +1,4 @@
1
- import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-all.min.js";export default class FilterList extends ShadowComponent{render(){return html`<slot></slot>`}filter(t){const e=t.toLowerCase().split(/\s+/).filter(t=>t.length>0);this.querySelectorAll("k-filter-item").forEach(t=>{const s=(t.getAttribute("filter-keywords")||"").toLowerCase();t.hidden=e.length>0&&!e.every(t=>s.includes(t))})}static styles=css`
1
+ import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-all.min.js";export default class FilterList extends ShadowComponent{#e=-1;render(){return html`<slot></slot>`}handleKeydown=e=>{const t=this.#t();t.length&&("ArrowDown"===e.key?(e.preventDefault(),this.#e=Math.min(this.#e+1,t.length-1),this.#s(t)):"ArrowUp"===e.key?(e.preventDefault(),this.#e=Math.max(this.#e-1,0),this.#s(t)):"Enter"===e.key&&this.#e>=0&&this.#e<t.length&&(e.preventDefault(),t[this.#e].querySelector("a")?.click()))};clearFocus=()=>{this.#e=-1,this.querySelectorAll("k-filter-item[kb-focus]").forEach(e=>e.removeAttribute("kb-focus"))};filter(e){const t=e.toLowerCase().split(/\s+/).filter(e=>e.length>0);this.querySelectorAll("k-filter-item").forEach(e=>{const s=(e.getAttribute("filter-keywords")||"").toLowerCase();e.hidden=t.length>0&&!t.every(e=>s.includes(e))}),this.clearFocus()}#t=()=>[...this.querySelectorAll("k-filter-item:not([hidden])")];#s=e=>{this.querySelectorAll("k-filter-item[kb-focus]").forEach(e=>e.removeAttribute("kb-focus")),e[this.#e]?.setAttribute("kb-focus",""),e[this.#e]?.scrollIntoView({block:"nearest"})};static styles=css`
2
2
  :host {
3
3
  display: block;
4
4
  }
@@ -0,0 +1,8 @@
1
+ import ShadowComponent from"./ShadowComponent.js";import{html,css}from"../lit-all.min.js";import theme from"../utils/theme.js";export default class ThemeSelect extends ShadowComponent{static properties={currentTheme:{type:String,reflect:!0,attribute:"current-theme"}};constructor(){super(),this.currentTheme=theme.get()}handleChange=e=>{theme.set(e.target.value)};connectedCallback(){super.connectedCallback(),this.unsubscribe=theme.subscribe(e=>{this.currentTheme=e})}disconnectedCallback(){super.disconnectedCallback(),this.unsubscribe&&this.unsubscribe()}render(){const e=this.childNodes.length>0;return html`
2
+ ${e?html`<label><slot></slot></label>`:""}
3
+ <select @change=${this.handleChange} .value=${this.currentTheme}>
4
+ <option value="light">Light</option>
5
+ <option value="dark">Dark</option>
6
+ <option value="auto">System Default</option>
7
+ </select>
8
+ `}static setTheme(e){theme.set(e)}static getCurrentTheme(){return theme.get()}static getCalculatedCurrentTheme(){return theme.getCalculated()}}customElements.define("k-theme-select",ThemeSelect);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -19,6 +19,9 @@ export default class FilterItem extends ShadowComponent {
19
19
  :host([hidden]) {
20
20
  display: none !important;
21
21
  }
22
+ :host([kb-focus]) ::slotted(a) {
23
+ background: rgba(128,128,128,.25);
24
+ }
22
25
  `;
23
26
  }
24
27
 
@@ -2,6 +2,8 @@ import ShadowComponent from './ShadowComponent.js';
2
2
  import { html, css } from '../lit-all.min.js';
3
3
 
4
4
  export default class FilterList extends ShadowComponent {
5
+ #focusedIndex = -1;
6
+
5
7
  /*
6
8
  Rendering
7
9
  */
@@ -12,14 +14,49 @@ export default class FilterList extends ShadowComponent {
12
14
  /*
13
15
  Public Methods
14
16
  */
17
+ handleKeydown = e => {
18
+ const items = this.#visibleItems();
19
+ if(!items.length) return;
20
+
21
+ if(e.key === 'ArrowDown') {
22
+ e.preventDefault();
23
+ this.#focusedIndex = Math.min(this.#focusedIndex + 1, items.length - 1);
24
+ this.#applyFocus(items);
25
+ } else if(e.key === 'ArrowUp') {
26
+ e.preventDefault();
27
+ this.#focusedIndex = Math.max(this.#focusedIndex - 1, 0);
28
+ this.#applyFocus(items);
29
+ } else if(e.key === 'Enter' && this.#focusedIndex >= 0 && this.#focusedIndex < items.length) {
30
+ e.preventDefault();
31
+ items[this.#focusedIndex].querySelector('a')?.click();
32
+ }
33
+ };
34
+
35
+ clearFocus = () => {
36
+ this.#focusedIndex = -1;
37
+ this.querySelectorAll('k-filter-item[kb-focus]').forEach(el => el.removeAttribute('kb-focus'));
38
+ };
39
+
15
40
  filter(term) {
16
41
  const words = term.toLowerCase().split(/\s+/).filter(w => w.length > 0);
17
42
  this.querySelectorAll('k-filter-item').forEach(item => {
18
43
  const keywords = (item.getAttribute('filter-keywords') || '').toLowerCase();
19
44
  item.hidden = words.length > 0 && !words.every(w => keywords.includes(w));
20
45
  });
46
+ this.clearFocus();
21
47
  }
22
48
 
49
+ /*
50
+ Private Methods
51
+ */
52
+ #visibleItems = () => [...this.querySelectorAll('k-filter-item:not([hidden])')];
53
+
54
+ #applyFocus = items => {
55
+ this.querySelectorAll('k-filter-item[kb-focus]').forEach(el => el.removeAttribute('kb-focus'));
56
+ items[this.#focusedIndex]?.setAttribute('kb-focus', '');
57
+ items[this.#focusedIndex]?.scrollIntoView({ block: 'nearest' });
58
+ };
59
+
23
60
  /*
24
61
  Styles
25
62
  */
@@ -0,0 +1,68 @@
1
+ import ShadowComponent from './ShadowComponent.js';
2
+ import { html, css } from '../lit-all.min.js';
3
+ import theme from '../utils/theme.js';
4
+
5
+ export default class ThemeSelect extends ShadowComponent {
6
+ static properties = {
7
+ currentTheme: { type: String, reflect: true, attribute: 'current-theme' }
8
+ };
9
+
10
+ constructor() {
11
+ super();
12
+ this.currentTheme = theme.get();
13
+ }
14
+
15
+ /*
16
+ Event Handlers
17
+ */
18
+ handleChange = e => {
19
+ theme.set(e.target.value);
20
+ }
21
+
22
+ /*
23
+ Lifecycle Callbacks
24
+ */
25
+ connectedCallback() {
26
+ super.connectedCallback();
27
+ this.unsubscribe = theme.subscribe(t => {
28
+ this.currentTheme = t;
29
+ });
30
+ }
31
+
32
+ disconnectedCallback() {
33
+ super.disconnectedCallback();
34
+ if(this.unsubscribe) this.unsubscribe();
35
+ }
36
+
37
+ /*
38
+ Rendering
39
+ */
40
+ render() {
41
+ const hasLabel = this.childNodes.length > 0;
42
+ return html`
43
+ ${hasLabel ? html`<label><slot></slot></label>` : ''}
44
+ <select @change=${this.handleChange} .value=${this.currentTheme}>
45
+ <option value="light">Light</option>
46
+ <option value="dark">Dark</option>
47
+ <option value="auto">System Default</option>
48
+ </select>
49
+ `;
50
+ }
51
+
52
+ /*
53
+ Static Methods
54
+ */
55
+ static setTheme(t) {
56
+ theme.set(t);
57
+ }
58
+
59
+ static getCurrentTheme() {
60
+ return theme.get();
61
+ }
62
+
63
+ static getCalculatedCurrentTheme() {
64
+ return theme.getCalculated();
65
+ }
66
+ }
67
+
68
+ customElements.define('k-theme-select', ThemeSelect);
@@ -302,6 +302,7 @@ export default {
302
302
 
303
303
  'should close on Escape key': async ({pass, fail}) => {
304
304
  const { container, dropdown } = await createDropdown({ opened: true });
305
+ dropdown.querySelector('button[data-value]').focus();
305
306
  const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
306
307
  document.dispatchEvent(event);
307
308
  if(dropdown.opened !== false) {
@@ -10,6 +10,17 @@ const createFilterList = async (items = []) => {
10
10
  return { container, el };
11
11
  };
12
12
 
13
+ const createFilterListWithLinks = async (items = []) => {
14
+ const container = document.createElement('div');
15
+ container.innerHTML = `<k-filter-list>${items.map(kw => `<k-filter-item filter-keywords="${kw}"><a href="#">${kw}</a></k-filter-item>`).join('')}</k-filter-list>`;
16
+ document.body.appendChild(container);
17
+ const el = container.querySelector('k-filter-list');
18
+ await el.updateComplete;
19
+ return { container, el };
20
+ };
21
+
22
+ const keydown = (el, key) => el.handleKeydown(new KeyboardEvent('keydown', { key, bubbles: true }));
23
+
13
24
  const cleanup = (container) => {
14
25
  if(container && container.parentNode){
15
26
  container.parentNode.removeChild(container);
@@ -159,4 +170,176 @@ export default {
159
170
  cleanup(container);
160
171
  pass('All items restored after clearing filter');
161
172
  },
173
+
174
+ /*
175
+ handleKeydown() — ArrowDown
176
+ */
177
+ 'ArrowDown should set kb-focus on the first item': async ({pass, fail}) => {
178
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana', 'cherry']);
179
+ keydown(el, 'ArrowDown');
180
+ const focused = el.querySelector('k-filter-item[kb-focus]');
181
+ if(!focused){
182
+ cleanup(container);
183
+ return fail('Expected a kb-focus item after ArrowDown');
184
+ }
185
+ if(focused.getAttribute('filter-keywords') !== 'apple'){
186
+ cleanup(container);
187
+ return fail(`Expected first item focused, got "${focused.getAttribute('filter-keywords')}"`);
188
+ }
189
+ cleanup(container);
190
+ pass('ArrowDown focuses first item');
191
+ },
192
+
193
+ 'ArrowDown twice should focus the second item': async ({pass, fail}) => {
194
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana', 'cherry']);
195
+ keydown(el, 'ArrowDown');
196
+ keydown(el, 'ArrowDown');
197
+ const focused = el.querySelector('k-filter-item[kb-focus]');
198
+ if(focused?.getAttribute('filter-keywords') !== 'banana'){
199
+ cleanup(container);
200
+ return fail(`Expected second item focused, got "${focused?.getAttribute('filter-keywords')}"`);
201
+ }
202
+ cleanup(container);
203
+ pass('ArrowDown twice focuses second item');
204
+ },
205
+
206
+ 'ArrowDown should not go past the last item': async ({pass, fail}) => {
207
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana']);
208
+ keydown(el, 'ArrowDown');
209
+ keydown(el, 'ArrowDown');
210
+ keydown(el, 'ArrowDown');
211
+ keydown(el, 'ArrowDown');
212
+ const focused = el.querySelector('k-filter-item[kb-focus]');
213
+ if(focused?.getAttribute('filter-keywords') !== 'banana'){
214
+ cleanup(container);
215
+ return fail('Focus should clamp at last item');
216
+ }
217
+ cleanup(container);
218
+ pass('ArrowDown clamps at last item');
219
+ },
220
+
221
+ 'ArrowDown should skip hidden items': async ({pass, fail}) => {
222
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana', 'cherry']);
223
+ el.filter('a');
224
+ keydown(el, 'ArrowDown');
225
+ const focused = el.querySelector('k-filter-item[kb-focus]');
226
+ if(focused?.hidden){
227
+ cleanup(container);
228
+ return fail('Focused item should not be hidden');
229
+ }
230
+ cleanup(container);
231
+ pass('ArrowDown skips hidden items');
232
+ },
233
+
234
+ /*
235
+ handleKeydown() — ArrowUp
236
+ */
237
+ 'ArrowUp should move focus backward': async ({pass, fail}) => {
238
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana', 'cherry']);
239
+ keydown(el, 'ArrowDown');
240
+ keydown(el, 'ArrowDown');
241
+ keydown(el, 'ArrowDown');
242
+ keydown(el, 'ArrowUp');
243
+ const focused = el.querySelector('k-filter-item[kb-focus]');
244
+ if(focused?.getAttribute('filter-keywords') !== 'banana'){
245
+ cleanup(container);
246
+ return fail(`Expected "banana" focused, got "${focused?.getAttribute('filter-keywords')}"`);
247
+ }
248
+ cleanup(container);
249
+ pass('ArrowUp moves focus backward');
250
+ },
251
+
252
+ 'ArrowUp should not go before the first item': async ({pass, fail}) => {
253
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana']);
254
+ keydown(el, 'ArrowDown');
255
+ keydown(el, 'ArrowUp');
256
+ keydown(el, 'ArrowUp');
257
+ const focused = el.querySelector('k-filter-item[kb-focus]');
258
+ if(focused?.getAttribute('filter-keywords') !== 'apple'){
259
+ cleanup(container);
260
+ return fail('Focus should clamp at first item');
261
+ }
262
+ cleanup(container);
263
+ pass('ArrowUp clamps at first item');
264
+ },
265
+
266
+ /*
267
+ handleKeydown() — Enter
268
+ */
269
+ 'Enter should click the link in the focused item': async ({pass, fail}) => {
270
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana']);
271
+ keydown(el, 'ArrowDown');
272
+ let clicked = false;
273
+ el.querySelector('k-filter-item[kb-focus] a').addEventListener('click', e => {
274
+ e.preventDefault();
275
+ clicked = true;
276
+ });
277
+ keydown(el, 'Enter');
278
+ if(!clicked){
279
+ cleanup(container);
280
+ return fail('Enter should click the focused link');
281
+ }
282
+ cleanup(container);
283
+ pass('Enter clicks the focused link');
284
+ },
285
+
286
+ 'Enter without focus should do nothing': async ({pass, fail}) => {
287
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana']);
288
+ keydown(el, 'Enter');
289
+ cleanup(container);
290
+ pass('Enter without focus does nothing');
291
+ },
292
+
293
+ /*
294
+ clearFocus()
295
+ */
296
+ 'clearFocus should remove kb-focus attribute': async ({pass, fail}) => {
297
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana']);
298
+ keydown(el, 'ArrowDown');
299
+ el.clearFocus();
300
+ const focused = el.querySelector('k-filter-item[kb-focus]');
301
+ if(focused){
302
+ cleanup(container);
303
+ return fail('clearFocus should remove all kb-focus attributes');
304
+ }
305
+ cleanup(container);
306
+ pass('clearFocus removes kb-focus');
307
+ },
308
+
309
+ 'filter() should reset keyboard focus': async ({pass, fail}) => {
310
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana']);
311
+ keydown(el, 'ArrowDown');
312
+ keydown(el, 'ArrowDown');
313
+ el.filter('');
314
+ const focused = el.querySelector('k-filter-item[kb-focus]');
315
+ if(focused){
316
+ cleanup(container);
317
+ return fail('filter() should reset keyboard focus');
318
+ }
319
+ keydown(el, 'ArrowDown');
320
+ const newFocused = el.querySelector('k-filter-item[kb-focus]');
321
+ if(newFocused?.getAttribute('filter-keywords') !== 'apple'){
322
+ cleanup(container);
323
+ return fail('After reset, ArrowDown should focus first item again');
324
+ }
325
+ cleanup(container);
326
+ pass('filter() resets keyboard focus');
327
+ },
328
+
329
+ /*
330
+ Only one kb-focus at a time
331
+ */
332
+ 'only one item should have kb-focus at a time': async ({pass, fail}) => {
333
+ const { container, el } = await createFilterListWithLinks(['apple', 'banana', 'cherry']);
334
+ keydown(el, 'ArrowDown');
335
+ keydown(el, 'ArrowDown');
336
+ keydown(el, 'ArrowDown');
337
+ const allFocused = el.querySelectorAll('k-filter-item[kb-focus]');
338
+ if(allFocused.length !== 1){
339
+ cleanup(container);
340
+ return fail(`Expected 1 kb-focus item, got ${allFocused.length}`);
341
+ }
342
+ cleanup(container);
343
+ pass('Only one item has kb-focus');
344
+ },
162
345
  };
@@ -0,0 +1,408 @@
1
+ import ThemeSelect from '../../src/components/ThemeSelect.js';
2
+ import theme from '../../src/utils/theme.js';
3
+
4
+ const createThemeSelect = async (innerHTML = '') => {
5
+ const container = document.createElement('div');
6
+ container.innerHTML = `<k-theme-select>${innerHTML}</k-theme-select>`;
7
+ document.body.appendChild(container);
8
+
9
+ const select = container.querySelector('k-theme-select');
10
+ await select.updateComplete;
11
+
12
+ return { container, select };
13
+ };
14
+
15
+ const cleanup = (container) => {
16
+ if(container && container.parentNode){
17
+ container.parentNode.removeChild(container);
18
+ }
19
+ theme.set('auto');
20
+ };
21
+
22
+ export default {
23
+ /*
24
+ ThemeSelect Component Tests
25
+ */
26
+ 'should create theme-select element': async ({pass, fail}) => {
27
+ const { container, select } = await createThemeSelect();
28
+
29
+ if(!select){
30
+ cleanup(container);
31
+ fail('ThemeSelect element should be created');
32
+ return;
33
+ }
34
+
35
+ if(!(select instanceof ThemeSelect)){
36
+ cleanup(container);
37
+ fail('Element should be instance of ThemeSelect');
38
+ return;
39
+ }
40
+
41
+ cleanup(container);
42
+ pass('ThemeSelect element created correctly');
43
+ },
44
+
45
+ 'should have shadow root': async ({pass, fail}) => {
46
+ const { container, select } = await createThemeSelect();
47
+
48
+ if(!select.shadowRoot){
49
+ cleanup(container);
50
+ fail('ThemeSelect should have shadow root');
51
+ return;
52
+ }
53
+
54
+ cleanup(container);
55
+ pass('ThemeSelect has shadow root');
56
+ },
57
+
58
+ 'should have currentTheme property': async ({pass, fail}) => {
59
+ const { container, select } = await createThemeSelect();
60
+
61
+ if(typeof select.currentTheme !== 'string'){
62
+ cleanup(container);
63
+ fail('ThemeSelect should have currentTheme property');
64
+ return;
65
+ }
66
+
67
+ cleanup(container);
68
+ pass('ThemeSelect has currentTheme property');
69
+ },
70
+
71
+ 'should reflect current-theme attribute': async ({pass, fail}) => {
72
+ const { container, select } = await createThemeSelect();
73
+
74
+ const currentTheme = select.getAttribute('current-theme');
75
+ if(!currentTheme){
76
+ cleanup(container);
77
+ fail('current-theme attribute should be set');
78
+ return;
79
+ }
80
+
81
+ cleanup(container);
82
+ pass('current-theme attribute reflects correctly');
83
+ },
84
+
85
+ 'should render select element': async ({pass, fail}) => {
86
+ const { container, select } = await createThemeSelect();
87
+
88
+ const selectEl = select.shadowRoot.querySelector('select');
89
+ if(!selectEl){
90
+ cleanup(container);
91
+ fail('ThemeSelect should render a select element');
92
+ return;
93
+ }
94
+
95
+ cleanup(container);
96
+ pass('ThemeSelect renders select element');
97
+ },
98
+
99
+ 'should render three options': async ({pass, fail}) => {
100
+ const { container, select } = await createThemeSelect();
101
+
102
+ const options = select.shadowRoot.querySelectorAll('option');
103
+ if(options.length !== 3){
104
+ cleanup(container);
105
+ fail(`Expected 3 options, got ${options.length}`);
106
+ return;
107
+ }
108
+
109
+ cleanup(container);
110
+ pass('ThemeSelect renders three options');
111
+ },
112
+
113
+ 'should have correct option values': async ({pass, fail}) => {
114
+ const { container, select } = await createThemeSelect();
115
+
116
+ const options = select.shadowRoot.querySelectorAll('option');
117
+ const values = Array.from(options).map(o => o.value);
118
+
119
+ if(!values.includes('light') || !values.includes('dark') || !values.includes('auto')){
120
+ cleanup(container);
121
+ fail(`Expected options with values light, dark, auto. Got ${values.join(', ')}`);
122
+ return;
123
+ }
124
+
125
+ cleanup(container);
126
+ pass('Options have correct values');
127
+ },
128
+
129
+ 'should have correct option labels': async ({pass, fail}) => {
130
+ const { container, select } = await createThemeSelect();
131
+
132
+ const options = select.shadowRoot.querySelectorAll('option');
133
+ const labels = Array.from(options).map(o => o.textContent);
134
+
135
+ if(!labels.includes('Light') || !labels.includes('Dark') || !labels.includes('System Default')){
136
+ cleanup(container);
137
+ fail(`Expected labels Light, Dark, System Default. Got ${labels.join(', ')}`);
138
+ return;
139
+ }
140
+
141
+ cleanup(container);
142
+ pass('Options have correct labels');
143
+ },
144
+
145
+ 'should not render label when no children': async ({pass, fail}) => {
146
+ const { container, select } = await createThemeSelect();
147
+
148
+ const label = select.shadowRoot.querySelector('label');
149
+ if(label){
150
+ cleanup(container);
151
+ fail('ThemeSelect should not render label when no children provided');
152
+ return;
153
+ }
154
+
155
+ cleanup(container);
156
+ pass('No label rendered without children');
157
+ },
158
+
159
+ 'should render label when children provided': async ({pass, fail}) => {
160
+ const { container, select } = await createThemeSelect('Theme');
161
+
162
+ const label = select.shadowRoot.querySelector('label');
163
+ if(!label){
164
+ cleanup(container);
165
+ fail('ThemeSelect should render label when children are provided');
166
+ return;
167
+ }
168
+
169
+ cleanup(container);
170
+ pass('Label rendered with children');
171
+ },
172
+
173
+ 'should set theme on select change to light': async ({pass, fail}) => {
174
+ const { container, select } = await createThemeSelect();
175
+
176
+ theme.set('auto');
177
+ await select.updateComplete;
178
+
179
+ const selectEl = select.shadowRoot.querySelector('select');
180
+ selectEl.value = 'light';
181
+ selectEl.dispatchEvent(new Event('change'));
182
+ await select.updateComplete;
183
+
184
+ if(theme.get() !== 'light'){
185
+ cleanup(container);
186
+ fail(`Expected theme "light", got "${theme.get()}"`);
187
+ return;
188
+ }
189
+
190
+ cleanup(container);
191
+ pass('Theme set to light on select change');
192
+ },
193
+
194
+ 'should set theme on select change to dark': async ({pass, fail}) => {
195
+ const { container, select } = await createThemeSelect();
196
+
197
+ theme.set('auto');
198
+ await select.updateComplete;
199
+
200
+ const selectEl = select.shadowRoot.querySelector('select');
201
+ selectEl.value = 'dark';
202
+ selectEl.dispatchEvent(new Event('change'));
203
+ await select.updateComplete;
204
+
205
+ if(theme.get() !== 'dark'){
206
+ cleanup(container);
207
+ fail(`Expected theme "dark", got "${theme.get()}"`);
208
+ return;
209
+ }
210
+
211
+ cleanup(container);
212
+ pass('Theme set to dark on select change');
213
+ },
214
+
215
+ 'should set theme on select change to auto': async ({pass, fail}) => {
216
+ const { container, select } = await createThemeSelect();
217
+
218
+ theme.set('light');
219
+ await select.updateComplete;
220
+
221
+ const selectEl = select.shadowRoot.querySelector('select');
222
+ selectEl.value = 'auto';
223
+ selectEl.dispatchEvent(new Event('change'));
224
+ await select.updateComplete;
225
+
226
+ if(theme.get() !== 'auto'){
227
+ cleanup(container);
228
+ fail(`Expected theme "auto", got "${theme.get()}"`);
229
+ return;
230
+ }
231
+
232
+ cleanup(container);
233
+ pass('Theme set to auto on select change');
234
+ },
235
+
236
+ 'should update currentTheme when theme changes': async ({pass, fail}) => {
237
+ const { container, select } = await createThemeSelect();
238
+
239
+ theme.set('dark');
240
+ await new Promise(r => setTimeout(r, 50));
241
+ await select.updateComplete;
242
+
243
+ if(select.currentTheme !== 'dark'){
244
+ cleanup(container);
245
+ fail(`Expected currentTheme "dark", got "${select.currentTheme}"`);
246
+ return;
247
+ }
248
+
249
+ cleanup(container);
250
+ pass('currentTheme updates when theme changes');
251
+ },
252
+
253
+ 'should update select value when theme changes externally': async ({pass, fail}) => {
254
+ const { container, select } = await createThemeSelect();
255
+
256
+ theme.set('dark');
257
+ await new Promise(r => setTimeout(r, 50));
258
+ await select.updateComplete;
259
+
260
+ const selectEl = select.shadowRoot.querySelector('select');
261
+ if(selectEl.value !== 'dark'){
262
+ cleanup(container);
263
+ fail(`Expected select value "dark", got "${selectEl.value}"`);
264
+ return;
265
+ }
266
+
267
+ cleanup(container);
268
+ pass('Select value syncs with external theme changes');
269
+ },
270
+
271
+ 'should have static setTheme method': async ({pass, fail}) => {
272
+ const { container, select } = await createThemeSelect();
273
+
274
+ if(typeof ThemeSelect.setTheme !== 'function'){
275
+ cleanup(container);
276
+ fail('ThemeSelect should have static setTheme method');
277
+ return;
278
+ }
279
+
280
+ cleanup(container);
281
+ pass('ThemeSelect has static setTheme method');
282
+ },
283
+
284
+ 'static setTheme should set theme': async ({pass, fail}) => {
285
+ const { container, select } = await createThemeSelect();
286
+
287
+ ThemeSelect.setTheme('light');
288
+
289
+ if(theme.get() !== 'light'){
290
+ cleanup(container);
291
+ fail(`Expected theme "light", got "${theme.get()}"`);
292
+ return;
293
+ }
294
+
295
+ cleanup(container);
296
+ pass('Static setTheme sets theme');
297
+ },
298
+
299
+ 'should have static getCurrentTheme method': async ({pass, fail}) => {
300
+ const { container, select } = await createThemeSelect();
301
+
302
+ if(typeof ThemeSelect.getCurrentTheme !== 'function'){
303
+ cleanup(container);
304
+ fail('ThemeSelect should have static getCurrentTheme method');
305
+ return;
306
+ }
307
+
308
+ cleanup(container);
309
+ pass('ThemeSelect has static getCurrentTheme method');
310
+ },
311
+
312
+ 'static getCurrentTheme should return current theme': async ({pass, fail}) => {
313
+ const { container, select } = await createThemeSelect();
314
+
315
+ theme.set('dark');
316
+ const current = ThemeSelect.getCurrentTheme();
317
+
318
+ if(current !== 'dark'){
319
+ cleanup(container);
320
+ fail(`Expected "dark", got "${current}"`);
321
+ return;
322
+ }
323
+
324
+ cleanup(container);
325
+ pass('Static getCurrentTheme returns current theme');
326
+ },
327
+
328
+ 'should have static getCalculatedCurrentTheme method': async ({pass, fail}) => {
329
+ const { container, select } = await createThemeSelect();
330
+
331
+ if(typeof ThemeSelect.getCalculatedCurrentTheme !== 'function'){
332
+ cleanup(container);
333
+ fail('ThemeSelect should have static getCalculatedCurrentTheme method');
334
+ return;
335
+ }
336
+
337
+ cleanup(container);
338
+ pass('ThemeSelect has static getCalculatedCurrentTheme method');
339
+ },
340
+
341
+ 'static getCalculatedCurrentTheme should return light or dark': async ({pass, fail}) => {
342
+ const { container, select } = await createThemeSelect();
343
+
344
+ theme.set('auto');
345
+ const calculated = ThemeSelect.getCalculatedCurrentTheme();
346
+
347
+ if(calculated !== 'light' && calculated !== 'dark'){
348
+ cleanup(container);
349
+ fail(`Expected "light" or "dark", got "${calculated}"`);
350
+ return;
351
+ }
352
+
353
+ cleanup(container);
354
+ pass('Static getCalculatedCurrentTheme returns light or dark');
355
+ },
356
+
357
+ 'should subscribe to theme changes on connect': async ({pass, fail}) => {
358
+ const { container, select } = await createThemeSelect();
359
+
360
+ if(typeof select.unsubscribe !== 'function'){
361
+ cleanup(container);
362
+ fail('ThemeSelect should have unsubscribe function after connect');
363
+ return;
364
+ }
365
+
366
+ cleanup(container);
367
+ pass('ThemeSelect subscribes to theme changes');
368
+ },
369
+
370
+ 'should unsubscribe on disconnect': async ({pass, fail}) => {
371
+ const { container, select } = await createThemeSelect();
372
+
373
+ const originalUnsubscribe = select.unsubscribe;
374
+ let unsubscribeCalled = false;
375
+ select.unsubscribe = () => {
376
+ unsubscribeCalled = true;
377
+ originalUnsubscribe();
378
+ };
379
+
380
+ select.disconnectedCallback();
381
+
382
+ if(!unsubscribeCalled){
383
+ cleanup(container);
384
+ fail('unsubscribe should be called on disconnect');
385
+ return;
386
+ }
387
+
388
+ cleanup(container);
389
+ pass('ThemeSelect unsubscribes on disconnect');
390
+ },
391
+
392
+ 'should sync with external theme changes': async ({pass, fail}) => {
393
+ const { container, select } = await createThemeSelect();
394
+
395
+ theme.set('dark');
396
+ await new Promise(r => setTimeout(r, 50));
397
+ await select.updateComplete;
398
+
399
+ if(select.currentTheme !== 'dark'){
400
+ cleanup(container);
401
+ fail(`Expected currentTheme to sync to "dark", got "${select.currentTheme}"`);
402
+ return;
403
+ }
404
+
405
+ cleanup(container);
406
+ pass('ThemeSelect syncs with external theme changes');
407
+ }
408
+ };