@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.
@@ -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'}">${selected ? selected.text : this.placeholder}</span>
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.0",
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": "817f0dfc3c345957ae7a63c113e22aa0f80a773f"
31
+ "gitHead": "352830a2d1bd846bd07ffc0084df32dc3f8bac83"
32
32
  }