custom-select-web-component 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # Custom Select Web Component
2
+
3
+ Reusable Web Component for select inputs with optional superuser editing and optional IndexedDB persistence.
4
+
5
+ ## Quick start
6
+
7
+ ### Use directly in a page
8
+
9
+ ```html
10
+ <script type="module" src="./src/custom-select.js"></script>
11
+
12
+ <custom-select label="Soil type">
13
+ <option value="">Pick one...</option>
14
+ <option value="clay">Clay</option>
15
+ <option value="sandy">Sandy</option>
16
+ </custom-select>
17
+ ```
18
+
19
+ ### Use as a package
20
+
21
+ ```js
22
+ import 'custom-select-web-component';
23
+ ```
24
+
25
+ ## API reference
26
+
27
+ ### Element
28
+
29
+ - Tag: `<custom-select>`
30
+
31
+ ### Attributes
32
+
33
+ - `label`: Label text shown above or left of the control.
34
+ - `editable`: Enables Edit... mode, add option, and delete selected option.
35
+ - `label-position="left"`: Places label to the left. Default is top layout.
36
+ - `storage-key`: Optional persistence key override.
37
+ - `id` / `name`: Used as persistence key fallback when `storage-key` is not set.
38
+
39
+ ### Properties
40
+
41
+ - `value: string`: Gets or sets selected value.
42
+
43
+ ### Methods
44
+
45
+ - `clearPersistedOptions(): Promise<void>`: Deletes this component's saved options from IndexedDB.
46
+ - `resetToInitialOptions(): Promise<void>`: Restores options from initial markup snapshot and persists that state when enabled.
47
+
48
+ ### Events
49
+
50
+ - `change`: Fired when selected value changes.
51
+ - `event.detail.value`
52
+ - `editmodechange`: Fired when edit mode toggles.
53
+ - `event.detail.editMode`
54
+ - `optionadded`: Fired when a new option is added from editor input.
55
+ - `event.detail.value`
56
+ - `event.detail.text`
57
+
58
+ ## Styling hooks
59
+
60
+ Set these CSS custom properties on `custom-select`:
61
+
62
+ - `--control-font-size`: Shared size for select and editor. Default `1rem`.
63
+ - `--label-font-size`: Optional label override. Defaults to `--control-font-size`.
64
+ - `--label-width`: Label column width for left layout. Default `8rem`.
65
+
66
+ Example:
67
+
68
+ ```css
69
+ custom-select {
70
+ --control-font-size: 1rem;
71
+ }
72
+
73
+ custom-select.left-label {
74
+ --label-width: 6rem;
75
+ }
76
+ ```
77
+
78
+ ## Persistence behavior
79
+
80
+ Persistence is enabled only when `editable` is present and browser IndexedDB is available.
81
+
82
+ Storage key resolution order:
83
+
84
+ 1. `storage-key`
85
+ 2. `id`
86
+ 3. `name`
87
+
88
+ Stored record key format:
89
+
90
+ - `dropdown-options:<resolved-key>`
91
+
92
+ ## Packaging and publish
93
+
94
+ Project is configured as a no-build ESM package.
95
+
96
+ - Entry point: `custom-select.js`
97
+ - Source: `src/custom-select.js`
98
+ - Manifest: `package.json`
99
+
100
+ Create local package tarball:
101
+
102
+ ```powershell
103
+ npm pack
104
+ ```
105
+
106
+ Install tarball in another project:
107
+
108
+ ```powershell
109
+ npm install ../path/to/custom-select-web-component-0.1.0.tgz
110
+ ```
111
+
112
+ Publish to npm:
113
+
114
+ ```powershell
115
+ npm login
116
+ npm publish --access public
117
+ ```
118
+
119
+ Before publishing, update in `package.json`:
120
+
121
+ - `name` (must be unique on npm)
122
+ - `version`
123
+ - `license`
124
+
125
+ ## Development
126
+
127
+ Run locally with a static server:
128
+
129
+ ```powershell
130
+ python -m http.server 5500
131
+ ```
132
+
133
+ Open:
134
+
135
+ - `http://localhost:5500`
136
+
137
+ ## Versioning and release checklist
138
+
139
+ Use semantic versioning:
140
+
141
+ - `MAJOR`: Breaking API changes
142
+ - `MINOR`: New backward-compatible features
143
+ - `PATCH`: Backward-compatible fixes
144
+
145
+ Suggested release flow:
146
+
147
+ 1. Ensure working tree is clean and all changes are committed.
148
+ 2. Update `package.json` version.
149
+ 3. Run local sanity test:
150
+
151
+ ```powershell
152
+ npm pack
153
+ ```
154
+
155
+ 4. Test package install in a separate project:
156
+
157
+ ```powershell
158
+ npm install ../path/to/custom-select-web-component-<version>.tgz
159
+ ```
160
+
161
+ 5. Verify import and basic behaviors:
162
+ - component registration
163
+ - `editable` flow
164
+ - persistence restore
165
+ - reset/clear methods
166
+
167
+ 6. Publish:
168
+
169
+ ```powershell
170
+ npm publish --access public
171
+ ```
172
+
173
+ 7. Create a git tag for the published version (recommended):
174
+
175
+ ```powershell
176
+ git tag v<version>
177
+ git push origin v<version>
178
+ ```
@@ -0,0 +1 @@
1
+ export { CustomSelect } from './src/custom-select.js';
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "custom-select-web-component",
3
+ "version": "0.1.0",
4
+ "description": "Editable custom-select Web Component with optional IndexedDB persistence",
5
+ "type": "module",
6
+ "main": "./custom-select.js",
7
+ "module": "./custom-select.js",
8
+ "exports": {
9
+ ".": "./custom-select.js",
10
+ "./custom-select": "./custom-select.js",
11
+ "./src/custom-select.js": "./src/custom-select.js"
12
+ },
13
+ "files": [
14
+ "custom-select.js",
15
+ "src/custom-select.js",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "web-component",
20
+ "custom-element",
21
+ "select",
22
+ "dropdown",
23
+ "indexeddb"
24
+ ],
25
+ "license": "MIT"
26
+ }
@@ -0,0 +1,672 @@
1
+ const template = document.createElement('template');
2
+ const EDIT_OPTION_VALUE = 'makeEditable';
3
+ const EDIT_OPTION_LABEL = 'Edit...';
4
+ const DB_NAME = 'custom-select-db';
5
+ const DB_VERSION = 1;
6
+ const STORE_NAME = 'options';
7
+
8
+ template.innerHTML = `
9
+ <style>
10
+ :host {
11
+ display: inline-block;
12
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
13
+ min-width: 220px;
14
+ --label-width: 8rem;
15
+ --control-font-size: 1rem;
16
+ }
17
+
18
+ .wrapper {
19
+ display: grid;
20
+ grid-template-columns: 1fr;
21
+ row-gap: 0.375rem;
22
+ }
23
+
24
+ .control {
25
+ position: relative;
26
+ }
27
+
28
+ .label {
29
+ display: block;
30
+ margin-bottom: 0;
31
+ font-size: var(--label-font-size, var(--control-font-size));
32
+ color: #334155;
33
+ }
34
+
35
+ :host([label-position="left"]) .wrapper {
36
+ grid-template-columns: var(--label-width) minmax(0, 1fr);
37
+ column-gap: 0.75rem;
38
+ row-gap: 0.5rem;
39
+ align-items: center;
40
+ }
41
+
42
+ :host([label-position="left"]) .label {
43
+ grid-column: 1;
44
+ grid-row: 1;
45
+ }
46
+
47
+ :host([label-position="left"]) .control {
48
+ grid-column: 2;
49
+ grid-row: 1;
50
+ }
51
+
52
+ select {
53
+ width: 100%;
54
+ padding: 0.625rem 0.75rem;
55
+ border: 1px solid #94a3b8;
56
+ border-radius: 0.5rem;
57
+ background: #ffffff;
58
+ font-family: inherit;
59
+ font-size: var(--control-font-size);
60
+ color: #0f172a;
61
+ outline: none;
62
+ transition: border-color 120ms ease, box-shadow 120ms ease;
63
+ appearance: none;
64
+ }
65
+
66
+ select:focus {
67
+ border-color: #0ea5e9;
68
+ box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.2);
69
+ }
70
+
71
+ .editor {
72
+ width: 100%;
73
+ margin-top: 0.5rem;
74
+ padding: 0.625rem 0.75rem;
75
+ border: 1px dashed #0ea5e9;
76
+ border-radius: 0.5rem;
77
+ background: #f0f9ff;
78
+ font-family: inherit;
79
+ font-size: var(--control-font-size);
80
+ color: #0f172a;
81
+ outline: none;
82
+ }
83
+
84
+ :host([label-position="left"]) .editor {
85
+ grid-column: 2;
86
+ grid-row: 2;
87
+ margin-top: 0;
88
+ }
89
+
90
+ .editor:focus {
91
+ box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.2);
92
+ }
93
+
94
+ .chevron {
95
+ position: absolute;
96
+ right: 0.75rem;
97
+ top: 50%;
98
+ transform: translateY(-50%);
99
+ pointer-events: none;
100
+ color: #64748b;
101
+ font-size: 0.75rem;
102
+ }
103
+ </style>
104
+
105
+ <div class="wrapper">
106
+ <label class="label" part="label"></label>
107
+ <div class="control">
108
+ <select part="select"></select>
109
+ <span class="chevron" aria-hidden="true">▼</span>
110
+ </div>
111
+ <input class="editor" type="text" placeholder="Type a new option and press Enter" hidden />
112
+ </div>
113
+ `;
114
+
115
+ export class CustomSelect extends HTMLElement {
116
+ static dbPromise = null;
117
+
118
+ static get observedAttributes() {
119
+ return ['label', 'editable'];
120
+ }
121
+
122
+ constructor() {
123
+ super();
124
+ this.attachShadow({ mode: 'open' });
125
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
126
+
127
+ this.labelEl = this.shadowRoot.querySelector('.label');
128
+ this.selectEl = this.shadowRoot.querySelector('select');
129
+ this.editorEl = this.shadowRoot.querySelector('.editor');
130
+ this.editMode = false;
131
+ this.lastNonEditValue = '';
132
+ this.initialOptionsSnapshot = [];
133
+ }
134
+
135
+ connectedCallback() {
136
+ this.renderLabel();
137
+ this.bootstrapOptions();
138
+ this.captureInitialOptionsSnapshot();
139
+
140
+ this.selectEl.addEventListener('change', this.onChange);
141
+ this.selectEl.addEventListener('click', this.onSelectClick);
142
+ this.selectEl.addEventListener('keydown', this.onKeyDown);
143
+ this.editorEl.addEventListener('keydown', this.onEditorKeyDown);
144
+
145
+ this.restorePersistedOptions();
146
+ }
147
+
148
+ disconnectedCallback() {
149
+ this.selectEl.removeEventListener('change', this.onChange);
150
+ this.selectEl.removeEventListener('click', this.onSelectClick);
151
+ this.selectEl.removeEventListener('keydown', this.onKeyDown);
152
+ this.editorEl.removeEventListener('keydown', this.onEditorKeyDown);
153
+ }
154
+
155
+ attributeChangedCallback(name) {
156
+ if (name === 'label') {
157
+ this.renderLabel();
158
+ }
159
+
160
+ if (name === 'editable') {
161
+ this.applyEditableState();
162
+ }
163
+ }
164
+
165
+ get value() {
166
+ return this.selectEl.value;
167
+ }
168
+
169
+ set value(nextValue) {
170
+ this.selectEl.value = nextValue;
171
+ }
172
+
173
+ onChange = () => {
174
+ if (this.selectEl.value === EDIT_OPTION_VALUE) {
175
+ if (!this.isEditable()) {
176
+ this.selectFirstRegularOption();
177
+ return;
178
+ }
179
+
180
+ if (this.editMode) {
181
+ this.setEditMode(false);
182
+ this.closeEditor();
183
+ this.selectFirstRegularOption();
184
+
185
+ return;
186
+ }
187
+
188
+ this.setEditMode(true);
189
+ this.selectFirstRegularOption();
190
+
191
+ this.openEditor();
192
+
193
+ return;
194
+ }
195
+
196
+ this.lastNonEditValue = this.selectEl.value;
197
+
198
+ // Re-dispatch a change event from the custom element itself.
199
+ this.dispatchEvent(
200
+ new CustomEvent('change', {
201
+ detail: { value: this.value },
202
+ bubbles: true,
203
+ composed: true,
204
+ })
205
+ );
206
+ };
207
+
208
+ onKeyDown = (event) => {
209
+ const isDeleteKey = event.key === 'Delete' || event.key === 'Backspace';
210
+
211
+ if (!isDeleteKey || !this.editMode || !this.isEditable()) {
212
+ return;
213
+ }
214
+
215
+ this.deleteSelectedOption();
216
+ };
217
+
218
+ onSelectClick = () => {
219
+ const onlyEditOptionLeft = this.getRegularOptions().length === 0;
220
+
221
+ if (!this.isEditable() || this.editMode || !onlyEditOptionLeft) {
222
+ return;
223
+ }
224
+
225
+ if (this.selectEl.value === EDIT_OPTION_VALUE) {
226
+ this.setEditMode(true);
227
+ this.openEditor();
228
+ }
229
+ };
230
+
231
+ onEditorKeyDown = (event) => {
232
+ if (event.key === 'Enter') {
233
+ event.preventDefault();
234
+ this.addOptionFromEditor();
235
+ return;
236
+ }
237
+
238
+ if (event.key === 'Escape') {
239
+ event.preventDefault();
240
+ this.setEditMode(false);
241
+ this.closeEditor();
242
+ }
243
+ };
244
+
245
+ renderLabel() {
246
+ const label = this.getAttribute('label') ?? '';
247
+ this.labelEl.textContent = label;
248
+ this.labelEl.hidden = label.length === 0;
249
+ }
250
+
251
+ bootstrapOptions() {
252
+ // Seed options from any light-DOM <option> children.
253
+ const lightDomOptions = this.getLightDomRegularOptions();
254
+
255
+ if (lightDomOptions.length === 0) {
256
+ this.selectEl.innerHTML = `
257
+ <option value="">Choose one...</option>
258
+ <option value="one">Option One</option>
259
+ <option value="two">Option Two</option>
260
+ `;
261
+ } else {
262
+ this.selectEl.replaceChildren(
263
+ ...lightDomOptions.map((option) => {
264
+ const clonedOption = option.cloneNode(true);
265
+ return clonedOption;
266
+ })
267
+ );
268
+ }
269
+
270
+ this.applyEditableState();
271
+ this.syncSelectionState();
272
+ }
273
+
274
+ getStorageIdentity() {
275
+ return this.getAttribute('storage-key') || this.id || this.getAttribute('name') || null;
276
+ }
277
+
278
+ getStorageKey() {
279
+ const identity = this.getStorageIdentity();
280
+ if (!identity) {
281
+ return null;
282
+ }
283
+
284
+ return `dropdown-options:${identity}`;
285
+ }
286
+
287
+ canPersist() {
288
+ return this.isEditable() && Boolean(this.getStorageKey()) && typeof indexedDB !== 'undefined';
289
+ }
290
+
291
+ static getDb() {
292
+ if (CustomSelect.dbPromise) {
293
+ return CustomSelect.dbPromise;
294
+ }
295
+
296
+ CustomSelect.dbPromise = new Promise((resolve, reject) => {
297
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
298
+
299
+ request.onupgradeneeded = () => {
300
+ const db = request.result;
301
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
302
+ db.createObjectStore(STORE_NAME, { keyPath: 'key' });
303
+ }
304
+ };
305
+
306
+ request.onsuccess = () => resolve(request.result);
307
+ request.onerror = () => reject(request.error);
308
+ });
309
+
310
+ return CustomSelect.dbPromise;
311
+ }
312
+
313
+ async getPersistedRecord() {
314
+ if (!this.canPersist()) {
315
+ return null;
316
+ }
317
+
318
+ const storageKey = this.getStorageKey();
319
+ const db = await CustomSelect.getDb();
320
+
321
+ return new Promise((resolve, reject) => {
322
+ const transaction = db.transaction(STORE_NAME, 'readonly');
323
+ const store = transaction.objectStore(STORE_NAME);
324
+ const request = store.get(storageKey);
325
+
326
+ request.onsuccess = () => resolve(request.result || null);
327
+ request.onerror = () => reject(request.error);
328
+ });
329
+ }
330
+
331
+ async persistCurrentOptions() {
332
+ if (!this.canPersist()) {
333
+ return;
334
+ }
335
+
336
+ const storageKey = this.getStorageKey();
337
+ const options = this.getRegularOptions().map((option) => ({
338
+ value: option.value,
339
+ text: option.textContent ?? '',
340
+ selected: option.selected,
341
+ }));
342
+
343
+ const db = await CustomSelect.getDb();
344
+ await new Promise((resolve, reject) => {
345
+ const transaction = db.transaction(STORE_NAME, 'readwrite');
346
+ const store = transaction.objectStore(STORE_NAME);
347
+ const request = store.put({ key: storageKey, options });
348
+
349
+ request.onsuccess = () => resolve();
350
+ request.onerror = () => reject(request.error);
351
+ });
352
+ }
353
+
354
+ async clearPersistedOptions() {
355
+ if (!this.canPersist()) {
356
+ return;
357
+ }
358
+
359
+ const storageKey = this.getStorageKey();
360
+ const db = await CustomSelect.getDb();
361
+
362
+ await new Promise((resolve, reject) => {
363
+ const transaction = db.transaction(STORE_NAME, 'readwrite');
364
+ const store = transaction.objectStore(STORE_NAME);
365
+ const request = store.delete(storageKey);
366
+
367
+ request.onsuccess = () => resolve();
368
+ request.onerror = () => reject(request.error);
369
+ });
370
+ }
371
+
372
+ captureInitialOptionsSnapshot() {
373
+ if (this.initialOptionsSnapshot.length > 0) {
374
+ return;
375
+ }
376
+
377
+ this.initialOptionsSnapshot = this.getLightDomRegularOptions().map((option) => ({
378
+ value: option.value,
379
+ text: option.textContent ?? '',
380
+ selected: option.selected,
381
+ }));
382
+ }
383
+
384
+ applyOptions(options) {
385
+ const normalizedOptions = options.map((item) => {
386
+ const option = document.createElement('option');
387
+ option.value = item.value;
388
+ option.textContent = item.text;
389
+ option.selected = Boolean(item.selected);
390
+ return option;
391
+ });
392
+
393
+ this.selectEl.replaceChildren(...normalizedOptions);
394
+
395
+ const lightDomOptions = this.getLightDomRegularOptions();
396
+ lightDomOptions.forEach((option) => option.remove());
397
+
398
+ normalizedOptions.forEach((option) => {
399
+ const lightDomOption = option.cloneNode(true);
400
+ this.appendChild(lightDomOption);
401
+ });
402
+
403
+ this.applyEditableState();
404
+ this.syncSelectionState();
405
+ }
406
+
407
+ async resetToInitialOptions() {
408
+ if (this.initialOptionsSnapshot.length === 0) {
409
+ return;
410
+ }
411
+
412
+ this.applyOptions(this.initialOptionsSnapshot);
413
+
414
+ if (this.canPersist()) {
415
+ await this.persistCurrentOptions();
416
+ }
417
+ }
418
+
419
+ async restorePersistedOptions() {
420
+ if (!this.canPersist()) {
421
+ return;
422
+ }
423
+
424
+ try {
425
+ const record = await this.getPersistedRecord();
426
+ if (!record || !Array.isArray(record.options) || record.options.length === 0) {
427
+ return;
428
+ }
429
+
430
+ this.applyOptions(record.options);
431
+ } catch (error) {
432
+ // Ignore persistence failures and keep component usable.
433
+ console.error('Failed to restore custom-select options', error);
434
+ }
435
+ }
436
+
437
+ isEditable() {
438
+ return this.hasAttribute('editable');
439
+ }
440
+
441
+ applyEditableState() {
442
+ if (this.isEditable()) {
443
+ this.ensureEditOption();
444
+ return;
445
+ }
446
+
447
+ if (this.editMode) {
448
+ this.setEditMode(false);
449
+ this.closeEditor();
450
+ }
451
+
452
+ this.removeEditOption();
453
+
454
+ if (this.selectEl.value === EDIT_OPTION_VALUE) {
455
+ this.selectFirstRegularOption();
456
+ }
457
+ }
458
+
459
+ selectFirstRegularOption() {
460
+ if (
461
+ this.lastNonEditValue &&
462
+ this.getRegularOptions().some((option) => option.value === this.lastNonEditValue)
463
+ ) {
464
+ this.selectEl.value = this.lastNonEditValue;
465
+ return;
466
+ }
467
+
468
+ const firstRegularOption = this.getRegularOptions()[0];
469
+ if (firstRegularOption) {
470
+ this.selectEl.value = firstRegularOption.value;
471
+ this.lastNonEditValue = firstRegularOption.value;
472
+ }
473
+ }
474
+
475
+ syncSelectionState() {
476
+ const selectedRegularOption = this.getRegularOptions().find((option) => option.selected);
477
+ if (selectedRegularOption) {
478
+ this.selectEl.value = selectedRegularOption.value;
479
+ this.lastNonEditValue = selectedRegularOption.value;
480
+ return;
481
+ }
482
+
483
+ this.selectFirstRegularOption();
484
+ }
485
+
486
+ setEditMode(enabled) {
487
+ this.editMode = enabled;
488
+ this.toggleAttribute('edit-mode', enabled);
489
+
490
+ this.dispatchEvent(
491
+ new CustomEvent('editmodechange', {
492
+ detail: { editMode: enabled },
493
+ bubbles: true,
494
+ composed: true,
495
+ })
496
+ );
497
+ }
498
+
499
+ openEditor() {
500
+ this.editorEl.hidden = false;
501
+ this.editorEl.value = '';
502
+ this.editorEl.focus();
503
+ }
504
+
505
+ closeEditor() {
506
+ this.editorEl.hidden = true;
507
+ this.editorEl.value = '';
508
+ this.selectEl.focus();
509
+ }
510
+
511
+ ensureEditOption() {
512
+ let editOption = this.selectEl.querySelector(`option[value="${EDIT_OPTION_VALUE}"]`);
513
+
514
+ if (!editOption) {
515
+ editOption = document.createElement('option');
516
+ editOption.value = EDIT_OPTION_VALUE;
517
+ }
518
+
519
+ editOption.textContent = EDIT_OPTION_LABEL;
520
+ this.selectEl.appendChild(editOption);
521
+ }
522
+
523
+ removeEditOption() {
524
+ const editOption = this.selectEl.querySelector(`option[value="${EDIT_OPTION_VALUE}"]`);
525
+ if (editOption) {
526
+ editOption.remove();
527
+ }
528
+ }
529
+
530
+ getRegularOptions() {
531
+ return Array.from(this.selectEl.options).filter(
532
+ (option) => option.value !== EDIT_OPTION_VALUE
533
+ );
534
+ }
535
+
536
+ getLightDomRegularOptions() {
537
+ return Array.from(this.querySelectorAll('option')).filter(
538
+ (option) => option.value !== EDIT_OPTION_VALUE
539
+ );
540
+ }
541
+
542
+ deleteSelectedOption() {
543
+ const selectedOption = this.selectEl.selectedOptions[0];
544
+
545
+ if (!selectedOption || selectedOption.value === EDIT_OPTION_VALUE) {
546
+ return;
547
+ }
548
+
549
+ const selectedValue = selectedOption.value;
550
+ const selectedText = selectedOption.textContent;
551
+
552
+ selectedOption.remove();
553
+
554
+ const lightDomOption = this.getLightDomRegularOptions().find(
555
+ (option) => option.value === selectedValue && option.textContent === selectedText
556
+ );
557
+
558
+ if (lightDomOption) {
559
+ lightDomOption.remove();
560
+ }
561
+
562
+ const remainingOptions = this.getRegularOptions();
563
+ if (remainingOptions.length > 0) {
564
+ this.selectEl.value = remainingOptions[0].value;
565
+ this.lastNonEditValue = this.selectEl.value;
566
+
567
+ this.persistCurrentOptions();
568
+ this.setEditMode(false);
569
+ this.closeEditor();
570
+
571
+ this.dispatchEvent(
572
+ new CustomEvent('change', {
573
+ detail: { value: this.value },
574
+ bubbles: true,
575
+ composed: true,
576
+ })
577
+ );
578
+ return;
579
+ }
580
+
581
+ this.selectEl.value = EDIT_OPTION_VALUE;
582
+ this.lastNonEditValue = '';
583
+ this.persistCurrentOptions();
584
+ this.setEditMode(false);
585
+ this.closeEditor();
586
+ }
587
+
588
+ addOptionFromEditor() {
589
+ if (!this.isEditable()) {
590
+ return;
591
+ }
592
+
593
+ const typedText = this.editorEl.value.trim();
594
+
595
+ if (!typedText) {
596
+ return;
597
+ }
598
+
599
+ const nextValue = this.createUniqueValueFromText(typedText);
600
+
601
+ const shadowOption = document.createElement('option');
602
+ shadowOption.value = nextValue;
603
+ shadowOption.textContent = typedText;
604
+
605
+ const editOption = this.selectEl.querySelector(`option[value="${EDIT_OPTION_VALUE}"]`);
606
+ if (editOption) {
607
+ this.selectEl.insertBefore(shadowOption, editOption);
608
+ } else {
609
+ this.selectEl.appendChild(shadowOption);
610
+ this.ensureEditOption();
611
+ }
612
+
613
+ const lightDomOption = document.createElement('option');
614
+ lightDomOption.value = nextValue;
615
+ lightDomOption.textContent = typedText;
616
+ this.appendChild(lightDomOption);
617
+
618
+ this.selectEl.value = nextValue;
619
+ this.lastNonEditValue = nextValue;
620
+ this.editorEl.value = '';
621
+
622
+ this.persistCurrentOptions();
623
+
624
+ this.dispatchEvent(
625
+ new CustomEvent('change', {
626
+ detail: { value: this.value },
627
+ bubbles: true,
628
+ composed: true,
629
+ })
630
+ );
631
+
632
+ this.dispatchEvent(
633
+ new CustomEvent('optionadded', {
634
+ detail: { value: nextValue, text: typedText },
635
+ bubbles: true,
636
+ composed: true,
637
+ })
638
+ );
639
+
640
+ this.setEditMode(false);
641
+ this.closeEditor();
642
+ }
643
+
644
+ createUniqueValueFromText(text) {
645
+ const baseValue =
646
+ text
647
+ .toLowerCase()
648
+ .replace(/[^a-z0-9]+/g, '-')
649
+ .replace(/(^-|-$)/g, '') || 'option';
650
+
651
+ const existingValues = new Set(
652
+ this.getRegularOptions().map((option) => option.value.toLowerCase())
653
+ );
654
+
655
+ if (!existingValues.has(baseValue)) {
656
+ return baseValue;
657
+ }
658
+
659
+ let counter = 2;
660
+ let candidate = `${baseValue}-${counter}`;
661
+ while (existingValues.has(candidate)) {
662
+ counter += 1;
663
+ candidate = `${baseValue}-${counter}`;
664
+ }
665
+
666
+ return candidate;
667
+ }
668
+ }
669
+
670
+ if (!customElements.get('custom-select')) {
671
+ customElements.define('custom-select', CustomSelect);
672
+ }