kempo-ui 0.3.13 → 0.3.15

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.
Files changed (75) hide show
  1. package/dist/components/Combobox.js +97 -0
  2. package/dist/components/Option.js +1 -0
  3. package/docs/components/accordion.html +2 -0
  4. package/docs/components/aside.html +2 -0
  5. package/docs/components/card.html +2 -0
  6. package/docs/components/code-editor.html +2 -0
  7. package/docs/components/color-picker.html +2 -0
  8. package/docs/components/combobox.html +612 -0
  9. package/docs/components/content-slider.html +2 -0
  10. package/docs/components/context.html +2 -0
  11. package/docs/components/dialog.html +2 -0
  12. package/docs/components/dropdown.html +2 -0
  13. package/docs/components/filter-list.html +2 -0
  14. package/docs/components/focus-capture.html +2 -0
  15. package/docs/components/html-editor.html +2 -0
  16. package/docs/components/hybrid-component.html +2 -0
  17. package/docs/components/icon.html +2 -0
  18. package/docs/components/import.html +2 -0
  19. package/docs/components/light-component.html +2 -0
  20. package/docs/components/nav-spacer.html +2 -0
  21. package/docs/components/nav.html +2 -0
  22. package/docs/components/photo-viewer.html +2 -0
  23. package/docs/components/resize.html +2 -0
  24. package/docs/components/shadow-component.html +2 -0
  25. package/docs/components/show-more.html +2 -0
  26. package/docs/components/sortable.html +2 -0
  27. package/docs/components/spinner.html +2 -0
  28. package/docs/components/split.html +2 -0
  29. package/docs/components/table.html +2 -0
  30. package/docs/components/tableControls.html +2 -0
  31. package/docs/components/tableCustomFields.html +2 -0
  32. package/docs/components/tableFetchRecords.html +2 -0
  33. package/docs/components/tableFieldSortHide.html +2 -0
  34. package/docs/components/tablePagination.html +2 -0
  35. package/docs/components/tablePlaceholder.html +2 -0
  36. package/docs/components/tableRecordEditing.html +2 -0
  37. package/docs/components/tableRecordFiltering.html +2 -0
  38. package/docs/components/tableRecordHiding.html +2 -0
  39. package/docs/components/tableRecordSearching.html +2 -0
  40. package/docs/components/tableRecordSelection.html +2 -0
  41. package/docs/components/tableRowControls.html +2 -0
  42. package/docs/components/tableServerSync.html +2 -0
  43. package/docs/components/tableSorting.html +2 -0
  44. package/docs/components/tabs.html +2 -0
  45. package/docs/components/tags.html +2 -0
  46. package/docs/components/theme-select.html +2 -0
  47. package/docs/components/theme-switcher.html +2 -0
  48. package/docs/components/timestamp.html +2 -0
  49. package/docs/components/toast.html +2 -0
  50. package/docs/components/toggle.html +2 -0
  51. package/docs/components/tree.html +2 -0
  52. package/docs/index.html +8 -0
  53. package/docs/src/components/Combobox.js +97 -0
  54. package/docs/src/components/Option.js +1 -0
  55. package/docs/utils/context.html +2 -0
  56. package/docs/utils/cookie.html +2 -0
  57. package/docs/utils/debounce.html +2 -0
  58. package/docs/utils/drag.html +2 -0
  59. package/docs/utils/elevation.html +2 -0
  60. package/docs/utils/formatTimestamp.html +2 -0
  61. package/docs/utils/object.html +2 -0
  62. package/docs/utils/propConverters.html +2 -0
  63. package/docs/utils/string.html +2 -0
  64. package/docs/utils/theme.html +2 -0
  65. package/docs/utils/toTitleCase.html +2 -0
  66. package/docs/utils/type.html +2 -0
  67. package/docs/utils/wait.html +2 -0
  68. package/docs-src/components/combobox.page.html +358 -0
  69. package/docs-src/index.page.html +6 -0
  70. package/docs-src/nav.fragment.html +2 -0
  71. package/llms.txt +1 -0
  72. package/package.json +2 -2
  73. package/src/components/Combobox.js +338 -0
  74. package/src/components/Option.js +19 -0
  75. package/tests/components/Combobox.browser-test.js +790 -0
@@ -0,0 +1,338 @@
1
+ import { html, css } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import debounce from '../utils/debounce.js';
4
+ import './Option.js';
5
+
6
+ export default class Combobox extends ShadowComponent {
7
+ static formAssociated = true;
8
+
9
+ static properties = {
10
+ value: { type: String, reflect: true },
11
+ name: { type: String, reflect: true },
12
+ placeholder: { type: String },
13
+ opened: { type: Boolean, reflect: true },
14
+ searching: { type: Boolean, reflect: true },
15
+ required: { type: Boolean, reflect: true },
16
+ requireMatch: { type: Boolean, reflect: true, attribute: 'require-match' },
17
+ disabled: { type: Boolean, reflect: true },
18
+ debounceMs: { type: Number, attribute: 'debounce-ms' },
19
+ maxVisible: { type: Number, attribute: 'max-visible' }
20
+ };
21
+
22
+ #focusedIndex = -1;
23
+ #options = [];
24
+
25
+ /*
26
+ Lifecycle Callbacks
27
+ */
28
+ constructor() {
29
+ super();
30
+ this.internals = this.attachInternals();
31
+ this.value = '';
32
+ this.name = '';
33
+ this.placeholder = '';
34
+ this.opened = false;
35
+ this.searching = false;
36
+ this.required = false;
37
+ this.requireMatch = false;
38
+ this.disabled = false;
39
+ this.debounceMs = 300;
40
+ this.maxVisible = 8;
41
+ this.#setupDebounce(300);
42
+ }
43
+
44
+ connectedCallback() {
45
+ super.connectedCallback();
46
+ document.addEventListener('click', this.handleDocumentClick);
47
+ this.#syncOptions();
48
+ }
49
+
50
+ disconnectedCallback() {
51
+ super.disconnectedCallback();
52
+ document.removeEventListener('click', this.handleDocumentClick);
53
+ }
54
+
55
+ childrenUpdated() {
56
+ this.#syncOptions();
57
+ }
58
+
59
+ updated(changedProperties) {
60
+ super.updated(changedProperties);
61
+ if(changedProperties.has('debounceMs')) {
62
+ this.#setupDebounce(this.debounceMs);
63
+ }
64
+ if(changedProperties.has('value') || changedProperties.has('required') || changedProperties.has('requireMatch')) {
65
+ this.#updateFormState();
66
+ }
67
+ }
68
+
69
+ formResetCallback() {
70
+ this.value = '';
71
+ this.#updateFormState();
72
+ }
73
+
74
+ formStateRestoreCallback(state) {
75
+ this.value = state;
76
+ }
77
+
78
+ /*
79
+ Event Handlers
80
+ */
81
+ handleInput = e => {
82
+ this.value = e.target.value;
83
+ this.opened = true;
84
+ this.#focusedIndex = -1;
85
+ this.#debouncedSearch(this.value);
86
+ };
87
+
88
+ handleKeydown = e => {
89
+ const items = this.#visibleOptions;
90
+ if(e.key === 'ArrowDown') {
91
+ e.preventDefault();
92
+ if(!this.opened) {
93
+ this.opened = true;
94
+ return;
95
+ }
96
+ this.#focusedIndex = Math.min(this.#focusedIndex + 1, items.length - 1);
97
+ this.requestUpdate();
98
+ this.#scrollFocusedIntoView();
99
+ } else if(e.key === 'ArrowUp') {
100
+ e.preventDefault();
101
+ this.#focusedIndex = Math.max(this.#focusedIndex - 1, 0);
102
+ this.requestUpdate();
103
+ this.#scrollFocusedIntoView();
104
+ } else if(e.key === 'Enter') {
105
+ e.preventDefault();
106
+ if(this.#focusedIndex >= 0 && this.#focusedIndex < items.length) {
107
+ this.#selectOption(items[this.#focusedIndex]);
108
+ }
109
+ } else if(e.key === 'Escape') {
110
+ e.preventDefault();
111
+ this.opened = false;
112
+ this.#focusedIndex = -1;
113
+ } else if(e.key === 'Tab') {
114
+ this.opened = false;
115
+ this.#focusedIndex = -1;
116
+ }
117
+ };
118
+
119
+ handleDocumentClick = e => {
120
+ if(!e.composedPath().includes(this)) {
121
+ this.opened = false;
122
+ this.#focusedIndex = -1;
123
+ }
124
+ };
125
+
126
+ handleFocus = () => {
127
+ this.opened = true;
128
+ };
129
+
130
+ handleOptionClick = e => {
131
+ const index = Number(e.currentTarget.dataset.index);
132
+ this.#selectOption(this.#visibleOptions[index]);
133
+ };
134
+
135
+ /*
136
+ Public Methods
137
+ */
138
+ setOptions(options) {
139
+ [...this.querySelectorAll('k-option')].forEach(o => o.remove());
140
+ options.forEach(opt => {
141
+ const el = document.createElement('k-option');
142
+ if(typeof opt === 'object') {
143
+ el.setAttribute('value', opt.value ?? opt.label);
144
+ el.textContent = opt.label;
145
+ } else {
146
+ el.textContent = opt;
147
+ }
148
+ this.appendChild(el);
149
+ });
150
+ return this;
151
+ }
152
+
153
+ clear() {
154
+ this.value = '';
155
+ this.opened = false;
156
+ this.#focusedIndex = -1;
157
+ this.#updateFormState();
158
+ return this;
159
+ }
160
+
161
+ /*
162
+ Private Methods
163
+ */
164
+ #setupDebounce(ms) {
165
+ this.#debouncedSearch = debounce(value => {
166
+ this.dispatchEvent(new CustomEvent('search', {
167
+ detail: { value },
168
+ bubbles: true,
169
+ composed: true
170
+ }));
171
+ }, ms);
172
+ }
173
+
174
+ #debouncedSearch = () => {};
175
+
176
+ #syncOptions() {
177
+ this.#options = [...this.querySelectorAll('k-option')].map(el => ({
178
+ label: el.label,
179
+ value: el.value
180
+ }));
181
+ this.requestUpdate();
182
+ }
183
+
184
+ #selectOption(option) {
185
+ this.value = option.label;
186
+ this.opened = false;
187
+ this.#focusedIndex = -1;
188
+ this.#updateFormState();
189
+ this.dispatchEvent(new CustomEvent('select', {
190
+ detail: { value: option.value, label: option.label },
191
+ bubbles: true,
192
+ composed: true
193
+ }));
194
+ this.dispatchEvent(new CustomEvent('change', {
195
+ bubbles: true
196
+ }));
197
+ }
198
+
199
+ #updateFormState() {
200
+ const matchedOption = this.#options.find(o => o.label === this.value);
201
+ this.internals.setFormValue(matchedOption ? matchedOption.value : this.value);
202
+ if(this.required && !this.value) {
203
+ this.internals.setValidity({ valueMissing: true }, 'Please select a value.', this.shadowRoot?.querySelector('input'));
204
+ } else if(this.requireMatch && this.value && !matchedOption) {
205
+ this.internals.setValidity({ customError: true }, 'Please select a valid option.', this.shadowRoot?.querySelector('input'));
206
+ } else {
207
+ this.internals.setValidity({});
208
+ }
209
+ }
210
+
211
+ #scrollFocusedIntoView() {
212
+ this.updateComplete.then(() => {
213
+ this.shadowRoot?.querySelector('.option.focused')?.scrollIntoView({ block: 'nearest' });
214
+ });
215
+ }
216
+
217
+ get #filteredOptions() {
218
+ const term = (this.value || '').toLowerCase();
219
+ if(!term) return this.#options;
220
+ return this.#options.filter(opt => opt.label.toLowerCase().includes(term));
221
+ }
222
+
223
+ get #visibleOptions() {
224
+ return this.#filteredOptions.slice(0, this.maxVisible);
225
+ }
226
+
227
+ /*
228
+ Rendering
229
+ */
230
+ render() {
231
+ const visible = this.#visibleOptions;
232
+ const hasMore = this.#filteredOptions.length > this.maxVisible;
233
+ return html`
234
+ <slot style="display:none"></slot>
235
+ <input
236
+ type="text"
237
+ .value=${this.value}
238
+ placeholder=${this.placeholder}
239
+ ?disabled=${this.disabled}
240
+ @input=${this.handleInput}
241
+ @keydown=${this.handleKeydown}
242
+ @focus=${this.handleFocus}
243
+ autocomplete="off"
244
+ />
245
+ ${this.opened ? html`
246
+ <div id="menu">
247
+ ${visible.map((opt, i) => html`
248
+ <div
249
+ class="option ${i === this.#focusedIndex ? 'focused' : ''}"
250
+ data-index=${i}
251
+ @click=${this.handleOptionClick}
252
+ >${opt.label}</div>
253
+ `)}
254
+ ${hasMore && !this.searching ? html`
255
+ <div class="more">${this.#filteredOptions.length - this.maxVisible} more...</div>
256
+ ` : ''}
257
+ ${visible.length === 0 && !this.searching ? html`
258
+ <div class="no-results">No matches</div>
259
+ ` : ''}
260
+ ${this.searching ? html`
261
+ <div class="searching">
262
+ <k-spinner size="xs"></k-spinner>
263
+ <span>Searching...</span>
264
+ </div>
265
+ ` : ''}
266
+ </div>
267
+ ` : ''}
268
+ `;
269
+ }
270
+
271
+ /*
272
+ Styles
273
+ */
274
+ static styles = css`
275
+ :host {
276
+ display: block;
277
+ position: relative;
278
+ }
279
+ :host([disabled]) {
280
+ opacity: 0.5;
281
+ pointer-events: none;
282
+ }
283
+ input {
284
+ width: 100%;
285
+ box-sizing: border-box;
286
+ padding: var(--spacer_h) var(--spacer);
287
+ border: 1px solid var(--c_border);
288
+ border-radius: var(--radius);
289
+ background: var(--c_bg);
290
+ color: var(--tc);
291
+ font: inherit;
292
+ outline: none;
293
+ transition: border-color var(--animation_ms);
294
+ }
295
+ input:focus {
296
+ border-color: var(--c_primary);
297
+ }
298
+ #menu {
299
+ position: absolute;
300
+ top: 100%;
301
+ left: 0;
302
+ right: 0;
303
+ z-index: 70;
304
+ max-height: 20rem;
305
+ overflow-y: auto;
306
+ background: var(--c_bg);
307
+ border: 1px solid var(--c_border);
308
+ border-radius: var(--radius);
309
+ box-shadow: var(--drop_shadow);
310
+ margin-top: 0.25rem;
311
+ }
312
+ .option {
313
+ padding: var(--spacer_h) var(--spacer);
314
+ cursor: pointer;
315
+ transition: background var(--animation_ms);
316
+ }
317
+ .option:hover,
318
+ .option.focused {
319
+ background: var(--c_bg__alt);
320
+ }
321
+ .no-results,
322
+ .more {
323
+ padding: var(--spacer_h) var(--spacer);
324
+ color: var(--tc_muted);
325
+ font-style: italic;
326
+ }
327
+ .searching {
328
+ display: flex;
329
+ align-items: center;
330
+ gap: 0.5rem;
331
+ padding: var(--spacer_h) var(--spacer);
332
+ color: var(--tc_muted);
333
+ border-top: 1px solid var(--c_border);
334
+ }
335
+ `;
336
+ }
337
+
338
+ customElements.define('k-combobox', Combobox);
@@ -0,0 +1,19 @@
1
+ export default class Option extends HTMLElement {
2
+ static get observedAttributes() {
3
+ return ['value'];
4
+ }
5
+
6
+ get value() {
7
+ return this.getAttribute('value') ?? this.textContent.trim();
8
+ }
9
+
10
+ set value(v) {
11
+ this.setAttribute('value', v);
12
+ }
13
+
14
+ get label() {
15
+ return this.textContent.trim();
16
+ }
17
+ }
18
+
19
+ customElements.define('k-option', Option);