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 +178 -0
- package/custom-select.js +1 -0
- package/package.json +26 -0
- package/src/custom-select.js +672 -0
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
|
+
```
|
package/custom-select.js
ADDED
|
@@ -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
|
+
}
|