@dile/ui 3.5.0 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-Selecting-a-result-clear---does-not-re-select-the-same-item--regression--infinite-add-loop-under-addOnSelect--1.png +0 -0
- package/components/select/select-ajax-overlay.spec.js +33 -0
- package/components/select/select-overlay.spec.js +89 -0
- package/components/select/src/DileSelectAjaxOverlay.js +18 -2
- package/components/select/src/DileSelectOverlay.js +50 -5
- package/package.json +2 -2
|
@@ -275,6 +275,39 @@ describe('dile-select-ajax-overlay', () => {
|
|
|
275
275
|
expect(el.value).toBeUndefined();
|
|
276
276
|
expect(el.shadowRoot.getElementById('search')).toBeTruthy();
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
it('clear() does not re-select the same item (regression: infinite add loop under addOnSelect)', async () => {
|
|
280
|
+
// Regression test: unlike onClearSelected() (triggered by the user's own "x" click), the
|
|
281
|
+
// public clear() API left `data`/`keyword` untouched. Flipping isSelected back to false
|
|
282
|
+
// remounts a brand new nested dile-select-overlay, whose firstUpdated() syncs its value to
|
|
283
|
+
// whatever the native <select> currently holds. With no placeholder option and the stale
|
|
284
|
+
// data still in place, that was the just-cleared item's own option — auto-selecting it and
|
|
285
|
+
// re-firing element-changed for the same value, which under dile-many-relation's
|
|
286
|
+
// addOnSelect re-added the same item forever until the API started rate-limiting.
|
|
287
|
+
mockFetch([{ id: 1, name: 'Spain' }]);
|
|
288
|
+
const el = await renderSelectAjaxOverlay(`
|
|
289
|
+
<dile-select-ajax-overlay endpoint="/api/countries" idProperty="id" displayProperty="name" delay="10"></dile-select-ajax-overlay>
|
|
290
|
+
`);
|
|
291
|
+
|
|
292
|
+
await typeKeyword(el, 'sp');
|
|
293
|
+
await selectOption(el, 0);
|
|
294
|
+
expect(el.value).toBe('1');
|
|
295
|
+
|
|
296
|
+
const changedValues = [];
|
|
297
|
+
el.addEventListener('element-changed', (e) => { changedValues.push(e.detail.value); });
|
|
298
|
+
|
|
299
|
+
el.clear();
|
|
300
|
+
await el.updateComplete;
|
|
301
|
+
await wait(20);
|
|
302
|
+
await el.updateComplete;
|
|
303
|
+
|
|
304
|
+
expect(el.isSelected).toBe(false);
|
|
305
|
+
expect(el.value).toBeUndefined();
|
|
306
|
+
expect(el.data).toEqual([]);
|
|
307
|
+
// Exactly one event: the clear itself. A second one carrying the old value ('1') back
|
|
308
|
+
// would mean the freshly remounted select auto-picked the stale option again.
|
|
309
|
+
expect(changedValues).toEqual([undefined]);
|
|
310
|
+
});
|
|
278
311
|
});
|
|
279
312
|
|
|
280
313
|
describe('Form Association', () => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
2
|
import './select-overlay.js';
|
|
3
|
+
import '@dile/iconlib/lucide-icons/house.js';
|
|
4
|
+
import '@dile/iconlib/material-icons/star.js';
|
|
3
5
|
|
|
4
6
|
describe('dile-select-overlay', () => {
|
|
5
7
|
afterEach(() => {
|
|
@@ -151,6 +153,93 @@ describe('dile-select-overlay', () => {
|
|
|
151
153
|
});
|
|
152
154
|
});
|
|
153
155
|
|
|
156
|
+
describe('Icons', () => {
|
|
157
|
+
it("shows the selected option's icon on the trigger, marked aria-hidden", async () => {
|
|
158
|
+
const el = await renderSelectOverlay(`
|
|
159
|
+
<dile-select-overlay value="2">
|
|
160
|
+
<select slot="select">
|
|
161
|
+
<option value="1">Option 1</option>
|
|
162
|
+
<option value="2" data-icon="lucide.house">Option 2</option>
|
|
163
|
+
</select>
|
|
164
|
+
</dile-select-overlay>
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
const icon = el.shadowRoot.querySelector('.trigger-text dile-iconlib');
|
|
168
|
+
expect(icon).toBeTruthy();
|
|
169
|
+
expect(icon.getAttribute('icon')).toBe('lucide.house');
|
|
170
|
+
expect(icon.getAttribute('aria-hidden')).toBe('true');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("does not show an icon on the trigger when the selected option has none", async () => {
|
|
174
|
+
const el = await renderSelectOverlay(`
|
|
175
|
+
<dile-select-overlay value="1">
|
|
176
|
+
<select slot="select">
|
|
177
|
+
<option value="1">Option 1</option>
|
|
178
|
+
<option value="2" data-icon="lucide.house">Option 2</option>
|
|
179
|
+
</select>
|
|
180
|
+
</dile-select-overlay>
|
|
181
|
+
`);
|
|
182
|
+
|
|
183
|
+
expect(el.shadowRoot.querySelector('.trigger-text dile-iconlib')).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("shows each option's icon in the listbox, keeping the option text as its accessible name", async () => {
|
|
187
|
+
const el = await renderSelectOverlay(`
|
|
188
|
+
<dile-select-overlay>
|
|
189
|
+
<select slot="select">
|
|
190
|
+
<option value="1" data-icon="lucide.house">Option 1</option>
|
|
191
|
+
<option value="2" data-icon="material.star">Option 2</option>
|
|
192
|
+
<option value="3">Option 3</option>
|
|
193
|
+
</select>
|
|
194
|
+
</dile-select-overlay>
|
|
195
|
+
`);
|
|
196
|
+
|
|
197
|
+
await open(el);
|
|
198
|
+
const items = el.shadowRoot.querySelectorAll('[role="option"]');
|
|
199
|
+
expect(items[0].querySelector('dile-iconlib').getAttribute('icon')).toBe('lucide.house');
|
|
200
|
+
expect(items[0].textContent.trim()).toBe('Option 1');
|
|
201
|
+
expect(items[1].querySelector('dile-iconlib').getAttribute('icon')).toBe('material.star');
|
|
202
|
+
expect(items[2].querySelector('dile-iconlib')).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('uses data-icon on the <select> as the default icon for options that do not declare their own', async () => {
|
|
206
|
+
const el = await renderSelectOverlay(`
|
|
207
|
+
<dile-select-overlay value="2">
|
|
208
|
+
<select slot="select" data-icon="lucide.house">
|
|
209
|
+
<option value="1">Option 1</option>
|
|
210
|
+
<option value="2" data-icon="material.star">Option 2</option>
|
|
211
|
+
</select>
|
|
212
|
+
</dile-select-overlay>
|
|
213
|
+
`);
|
|
214
|
+
|
|
215
|
+
await open(el);
|
|
216
|
+
const items = el.shadowRoot.querySelectorAll('[role="option"]');
|
|
217
|
+
// Option 1 has no icon of its own, so it falls back to the select-level default.
|
|
218
|
+
expect(items[0].querySelector('dile-iconlib').getAttribute('icon')).toBe('lucide.house');
|
|
219
|
+
// Option 2 declares its own icon, which takes precedence over the select-level default.
|
|
220
|
+
expect(items[1].querySelector('dile-iconlib').getAttribute('icon')).toBe('material.star');
|
|
221
|
+
// The trigger shows the selected option's own icon too.
|
|
222
|
+
expect(el.shadowRoot.querySelector('.trigger-text dile-iconlib').getAttribute('icon')).toBe('material.star');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('warns and skips rendering an icon that has not been imported/registered', async () => {
|
|
226
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
227
|
+
const el = await renderSelectOverlay(`
|
|
228
|
+
<dile-select-overlay>
|
|
229
|
+
<select slot="select">
|
|
230
|
+
<option value="1" data-icon="lucide.this-icon-does-not-exist">Option 1</option>
|
|
231
|
+
</select>
|
|
232
|
+
</dile-select-overlay>
|
|
233
|
+
`);
|
|
234
|
+
|
|
235
|
+
await open(el);
|
|
236
|
+
const item = el.shadowRoot.querySelector('[role="option"]');
|
|
237
|
+
expect(item.querySelector('dile-iconlib')).toBeNull();
|
|
238
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
239
|
+
warnSpy.mockRestore();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
154
243
|
describe('Opening and closing', () => {
|
|
155
244
|
it('opens the listbox when the trigger is clicked', async () => {
|
|
156
245
|
const el = await renderSelectOverlay(`
|
|
@@ -135,6 +135,12 @@ export class DileSelectAjaxOverlay extends DileEmmitChange(LitElement) {
|
|
|
135
135
|
resultDataProperty: { type: String },
|
|
136
136
|
displayProperty: { type: String },
|
|
137
137
|
idProperty: { type: String },
|
|
138
|
+
/** Property in each data object holding a dile-iconlib "family.name" icon string, applied
|
|
139
|
+
* per option. Leave unset if you don't need per-option icons. */
|
|
140
|
+
iconProperty: { type: String },
|
|
141
|
+
/** dile-iconlib "family.name" icon string applied to every option that has no icon of its
|
|
142
|
+
* own via iconProperty. */
|
|
143
|
+
icon: { type: String },
|
|
138
144
|
delay: { type: Number },
|
|
139
145
|
keyword: {
|
|
140
146
|
type: String,
|
|
@@ -342,9 +348,9 @@ export class DileSelectAjaxOverlay extends DileEmmitChange(LitElement) {
|
|
|
342
348
|
?disabled=${this.disabled}
|
|
343
349
|
@element-changed=${this.doSelected}
|
|
344
350
|
>
|
|
345
|
-
<select slot="select">
|
|
351
|
+
<select slot="select" data-icon="${ifDefined(this.icon)}">
|
|
346
352
|
${this.data.map(item => html`
|
|
347
|
-
<option value="${item[this.idProperty]}">${item[this.displayProperty]}</option>
|
|
353
|
+
<option value="${item[this.idProperty]}" data-icon="${ifDefined(this.iconProperty ? item[this.iconProperty] : undefined)}">${item[this.displayProperty]}</option>
|
|
348
354
|
`)}
|
|
349
355
|
</select>
|
|
350
356
|
</dile-select-overlay>
|
|
@@ -499,6 +505,16 @@ export class DileSelectAjaxOverlay extends DileEmmitChange(LitElement) {
|
|
|
499
505
|
this.isSelected = false;
|
|
500
506
|
this.value = undefined;
|
|
501
507
|
this.selectedText = '';
|
|
508
|
+
// isSelected flipping back to false remounts a brand new nested dile-select-overlay (the
|
|
509
|
+
// read-only and search branches are mutually exclusive templates). Its firstUpdated() syncs
|
|
510
|
+
// its value to the native <select>'s current value, and this component's <select> has no
|
|
511
|
+
// placeholder option — so leftover data/keyword from the previous search would leave a real
|
|
512
|
+
// option in place, get auto-picked as the new instance's value, and re-fire element-changed
|
|
513
|
+
// for the very same item that was just cleared (an infinite loop under addOnSelect).
|
|
514
|
+
if (!this.static) {
|
|
515
|
+
this.keyword = '';
|
|
516
|
+
this.data = [];
|
|
517
|
+
}
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
set(value) {
|
|
@@ -4,8 +4,12 @@ import { DileEmmitChange } from '../../../mixins/form/index.js';
|
|
|
4
4
|
import { DileOverlay } from '../../../mixins/overlay/index.js';
|
|
5
5
|
import { DileCloseDocumentClick } from '../../../mixins/close-document-click/index.js';
|
|
6
6
|
import { labelStyles, messageStyles } from '../../input/index.js';
|
|
7
|
+
import '@dile/iconlib/dile-iconlib.js';
|
|
7
8
|
|
|
8
9
|
const TYPEAHEAD_RESET_DELAY = 500;
|
|
10
|
+
// Mirrors DileSelectorItem's renderIconlibIcon() folder map/registration-warning convention.
|
|
11
|
+
const ICONLIB_FOLDERS = { material: 'material-icons', lucide: 'lucide-icons', fontawesome: 'fontawesome-icons' };
|
|
12
|
+
const warnedIcons = new Set();
|
|
9
13
|
|
|
10
14
|
export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEmmitChange(LitElement))) {
|
|
11
15
|
static get styles() {
|
|
@@ -69,13 +73,28 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
|
|
|
69
73
|
pointer-events: none;
|
|
70
74
|
}
|
|
71
75
|
.trigger-text {
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
gap: 0.4rem;
|
|
72
79
|
overflow: hidden;
|
|
73
|
-
text-overflow: ellipsis;
|
|
74
|
-
white-space: nowrap;
|
|
75
80
|
}
|
|
76
81
|
.trigger-text.placeholder {
|
|
77
82
|
color: var(--dile-input-placeholder-color, #ccc);
|
|
78
83
|
}
|
|
84
|
+
.trigger-label {
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
}
|
|
89
|
+
.option-icon {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
flex: none;
|
|
93
|
+
}
|
|
94
|
+
.option-icon dile-iconlib {
|
|
95
|
+
--dile-icon-size: var(--dile-select-overlay-option-icon-size, 16px);
|
|
96
|
+
--dile-icon-color: var(--dile-select-overlay-option-icon-color, currentColor);
|
|
97
|
+
}
|
|
79
98
|
.arrow-icon {
|
|
80
99
|
width: 18px;
|
|
81
100
|
height: 18px;
|
|
@@ -115,6 +134,9 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
|
|
|
115
134
|
transform: translateY(0);
|
|
116
135
|
}
|
|
117
136
|
.option {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 0.4rem;
|
|
118
140
|
padding: var(--dile-select-overlay-option-padding, 7px 10px);
|
|
119
141
|
cursor: pointer;
|
|
120
142
|
color: var(--dile-select-overlay-color, #303030);
|
|
@@ -193,7 +215,7 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
|
|
|
193
215
|
|
|
194
216
|
get options() {
|
|
195
217
|
return this.elselect
|
|
196
|
-
? Array.from(this.elselect.options).map(o => ({ value: o.value, text: o.textContent }))
|
|
218
|
+
? Array.from(this.elselect.options).map(o => ({ value: o.value, text: o.textContent, icon: o.dataset.icon || this.elselect.dataset.icon }))
|
|
197
219
|
: [];
|
|
198
220
|
}
|
|
199
221
|
|
|
@@ -319,7 +341,10 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
|
|
|
319
341
|
@click="${this._onTriggerClick}"
|
|
320
342
|
@keydown="${this.handleExternalKeydown}"
|
|
321
343
|
>
|
|
322
|
-
<span class="trigger-text ${selected ? '' : 'placeholder'}"
|
|
344
|
+
<span class="trigger-text ${selected ? '' : 'placeholder'}">
|
|
345
|
+
${selected && selected.icon ? html`<span class="option-icon">${this.renderIconlibIcon(selected.icon)}</span>` : ''}
|
|
346
|
+
<span class="trigger-label">${selected ? selected.text : this.placeholder}</span>
|
|
347
|
+
</span>
|
|
323
348
|
<span class="arrow-icon" aria-hidden="true">
|
|
324
349
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 448" enable-background="new 0 0 256 448">
|
|
325
350
|
<path d="M255.9 168c0-4.2-1.6-7.9-4.8-11.2-3.2-3.2-6.9-4.8-11.2-4.8H16c-4.2 0-7.9 1.6-11.2 4.8S0 163.8 0 168c0 4.4 1.6 8.2 4.8 11.4l112 112c3.1 3.1 6.8 4.6 11.2 4.6 4.4 0 8.2-1.5 11.4-4.6l112-112c3-3.2 4.5-7 4.5-11.4z"></path>
|
|
@@ -341,12 +366,32 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
|
|
|
341
366
|
aria-selected="${opt.value === (this.value ?? '')}"
|
|
342
367
|
@click="${() => this.selectOption(index)}"
|
|
343
368
|
@mousemove="${() => this._setActiveIndex(index, false)}"
|
|
344
|
-
>${opt.text}</li>
|
|
369
|
+
>${opt.icon ? html`<span class="option-icon">${this.renderIconlibIcon(opt.icon)}</span>` : ''}${opt.text}</li>
|
|
345
370
|
`)}
|
|
346
371
|
</ul>
|
|
347
372
|
`;
|
|
348
373
|
}
|
|
349
374
|
|
|
375
|
+
renderIconlibIcon(icon) {
|
|
376
|
+
const dotIndex = icon.indexOf('.');
|
|
377
|
+
if (dotIndex === -1) {
|
|
378
|
+
return html`<dile-iconlib icon="${icon}" aria-hidden="true"></dile-iconlib>`;
|
|
379
|
+
}
|
|
380
|
+
const family = icon.slice(0, dotIndex);
|
|
381
|
+
const name = icon.slice(dotIndex + 1);
|
|
382
|
+
const tagName = `dile-${family}-icon-${name}`;
|
|
383
|
+
if (customElements.get(tagName)) {
|
|
384
|
+
return html`<dile-iconlib icon="${icon}" aria-hidden="true"></dile-iconlib>`;
|
|
385
|
+
}
|
|
386
|
+
if (!warnedIcons.has(icon)) {
|
|
387
|
+
warnedIcons.add(icon);
|
|
388
|
+
const folder = ICONLIB_FOLDERS[family] || family;
|
|
389
|
+
console.warn(`dile-select-overlay: icon "${icon}" is not registered. Import "@dile/iconlib/${folder}/${name}.js" before using it.`);
|
|
390
|
+
}
|
|
391
|
+
customElements.whenDefined(tagName).then(() => this.requestUpdate());
|
|
392
|
+
return '';
|
|
393
|
+
}
|
|
394
|
+
|
|
350
395
|
_optionId(index) {
|
|
351
396
|
return `${this.selectId}-option-${index}`;
|
|
352
397
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dile/ui",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"description": "UI Core components from dile-components.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -28,5 +28,5 @@
|
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "352830a2d1bd846bd07ffc0084df32dc3f8bac83"
|
|
32
32
|
}
|