@dile/ui 3.5.1 → 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.
@@ -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>
@@ -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.1",
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": "7bcf203fb23acc24867e9f3df4f3ca2367eabd97"
31
+ "gitHead": "352830a2d1bd846bd07ffc0084df32dc3f8bac83"
32
32
  }