@dile/ui 3.18.5 → 3.19.1

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.
@@ -0,0 +1,499 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
3
+ import '@dile/iconlib/dile-iconlib.js';
4
+
5
+ const HOST_FOCUS_ROLES = ['option', 'menuitem'];
6
+ const INTERACTIVE_TAGS = ['button', 'input', 'select', 'textarea', 'a', 'label', 'summary', 'dile-list-item'];
7
+ const INTERACTIVE_ROLES = ['button', 'link', 'checkbox', 'switch', 'radio', 'menuitem', 'option', 'tab', 'textbox'];
8
+
9
+ export class DileListItem extends LitElement {
10
+ static get styles() {
11
+ return css`
12
+ :host {
13
+ display: block;
14
+ box-sizing: border-box;
15
+ outline: none;
16
+ --_padding-y: var(--dile-list-item-padding-y, 0.75rem);
17
+ --_padding-x: var(--dile-list-item-padding-x, 1rem);
18
+ --_gap: var(--dile-list-item-gap, 0.75rem);
19
+ --_align: center;
20
+ --_divider-border: var(--dile-list-item-divider-width, 1px) var(--dile-list-item-divider-style, solid) var(--dile-list-item-divider-color, #e5e7eb);
21
+ }
22
+
23
+ * {
24
+ box-sizing: border-box;
25
+ }
26
+
27
+ :host([density="compact"]) {
28
+ --_padding-y: var(--dile-list-item-padding-y, 0.375rem);
29
+ --_padding-x: var(--dile-list-item-padding-x, 0.75rem);
30
+ --_gap: var(--dile-list-item-gap, 0.5rem);
31
+ }
32
+
33
+ :host([density="comfortable"]) {
34
+ --_padding-y: var(--dile-list-item-padding-y, 0.75rem);
35
+ --_padding-x: var(--dile-list-item-padding-x, 1rem);
36
+ --_gap: var(--dile-list-item-gap, 0.75rem);
37
+ }
38
+
39
+ :host([density="spacious"]) {
40
+ --_padding-y: var(--dile-list-item-padding-y, 1.25rem);
41
+ --_padding-x: var(--dile-list-item-padding-x, 1.25rem);
42
+ --_gap: var(--dile-list-item-gap, 1rem);
43
+ }
44
+
45
+ :host([align="start"]) {
46
+ --_align: flex-start;
47
+ }
48
+
49
+ :host([align="center"]) {
50
+ --_align: center;
51
+ }
52
+
53
+ :host([align="end"]) {
54
+ --_align: flex-end;
55
+ }
56
+
57
+ :host([divider]:not([divider="inset"]):not([divider="none"])) {
58
+ border-bottom: var(--_divider-border);
59
+ }
60
+
61
+ .item {
62
+ display: flex;
63
+ box-sizing: border-box;
64
+ width: 100%;
65
+ margin: 0;
66
+ text-decoration: none;
67
+ color: var(--dile-list-item-color, inherit);
68
+ background-color: var(--dile-list-item-bg, transparent);
69
+ border-radius: var(--dile-list-item-border-radius, 4px);
70
+ padding-top: var(--_padding-y);
71
+ padding-bottom: var(--_padding-y);
72
+ padding-left: var(--_padding-x);
73
+ padding-right: var(--_padding-x);
74
+ gap: var(--_gap);
75
+ align-items: stretch;
76
+ transition-property: background-color, color, border-color, box-shadow;
77
+ transition-duration: var(--dile-list-item-transition-duration, 0.15s);
78
+ transition-timing-function: ease;
79
+ outline: none;
80
+ }
81
+
82
+ .item:focus-visible,
83
+ :host(:focus-visible) .item {
84
+ outline: var(--dile-list-item-focus-ring-width, 2px) solid var(--dile-list-item-focus-ring-color, #0284c7);
85
+ outline-offset: var(--dile-list-item-focus-ring-offset, -2px);
86
+ }
87
+
88
+ :host([clickable]:not([disabled])) .item,
89
+ :host([href]:not([href=""]):not([disabled])) .item {
90
+ cursor: pointer;
91
+ user-select: none;
92
+ }
93
+
94
+ :host([clickable]:not([disabled])) .item:hover,
95
+ :host([href]:not([href=""]):not([disabled])) .item:hover {
96
+ background-color: var(--dile-list-item-bg-hover, var(--dile-surface-hover-color, #f5f5f5));
97
+ }
98
+
99
+ :host([clickable]:not([disabled])) .item:active,
100
+ :host([href]:not([href=""]):not([disabled])) .item:active {
101
+ background-color: var(--dile-list-item-bg-active, var(--dile-surface-active-color, #ebebeb));
102
+ }
103
+
104
+ :host([selected]) .item {
105
+ background-color: var(--dile-list-item-bg-selected, #e0f2fe);
106
+ color: var(--dile-list-item-selected-text-color, var(--dile-primary-dark-color, #0369a1));
107
+ }
108
+
109
+ :host([disabled]) {
110
+ opacity: var(--dile-list-item-disabled-opacity, 0.5);
111
+ cursor: not-allowed;
112
+ }
113
+
114
+ :host([disabled]) .item {
115
+ pointer-events: none;
116
+ }
117
+
118
+ .prefix {
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ flex-shrink: 0;
123
+ align-self: var(--dile-list-item-prefix-align, var(--_align));
124
+ }
125
+
126
+ .prefix[hidden],
127
+ .suffix[hidden] {
128
+ display: none !important;
129
+ }
130
+
131
+ .prefix dile-iconlib,
132
+ .suffix dile-iconlib {
133
+ --dile-icon-size: var(--dile-list-item-icon-size, 1.25rem);
134
+ --dile-icon-color: var(--dile-list-item-icon-color, currentColor);
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ }
139
+
140
+ :host([selected]) .prefix dile-iconlib,
141
+ :host([selected]) .suffix dile-iconlib {
142
+ --dile-icon-color: var(--dile-list-item-selected-icon-color, var(--dile-list-item-selected-text-color, currentColor));
143
+ }
144
+
145
+ .content {
146
+ display: flex;
147
+ flex: 1 1 auto;
148
+ min-width: 0;
149
+ align-items: stretch;
150
+ gap: var(--_gap);
151
+ }
152
+
153
+ :host([divider="inset"]) .content {
154
+ border-bottom: var(--_divider-border);
155
+ }
156
+
157
+ .body {
158
+ flex: 1 1 auto;
159
+ min-width: 0;
160
+ align-self: center;
161
+ text-align: var(--dile-list-item-text-align, left);
162
+ }
163
+
164
+ :host([body-align="left"]) .body {
165
+ text-align: left;
166
+ }
167
+
168
+ :host([body-align="center"]) .body {
169
+ text-align: center;
170
+ }
171
+
172
+ :host([body-align="right"]) .body {
173
+ text-align: right;
174
+ }
175
+
176
+ .body ::slotted(p),
177
+ .body ::slotted(h1),
178
+ .body ::slotted(h2),
179
+ .body ::slotted(h3),
180
+ .body ::slotted(h4),
181
+ .body ::slotted(h5),
182
+ .body ::slotted(h6) {
183
+ margin-top: 0;
184
+ margin-bottom: 0;
185
+ }
186
+
187
+ :host([lines="1"]) .body {
188
+ display: -webkit-box;
189
+ -webkit-line-clamp: 1;
190
+ -webkit-box-orient: vertical;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ }
194
+
195
+ :host([lines="2"]) .body {
196
+ display: -webkit-box;
197
+ -webkit-line-clamp: 2;
198
+ -webkit-box-orient: vertical;
199
+ overflow: hidden;
200
+ text-overflow: ellipsis;
201
+ }
202
+
203
+ :host([lines]:not([lines="1"]):not([lines="2"]):not([lines="auto"]):not([lines=""])) .body {
204
+ display: -webkit-box;
205
+ -webkit-line-clamp: var(--_lines, 1);
206
+ -webkit-box-orient: vertical;
207
+ overflow: hidden;
208
+ text-overflow: ellipsis;
209
+ }
210
+
211
+ .suffix {
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ flex-shrink: 0;
216
+ align-self: var(--dile-list-item-suffix-align, var(--_align));
217
+ }
218
+ `;
219
+ }
220
+
221
+ static get properties() {
222
+ return {
223
+ /** Icon identifier in "family.name" format for prefix */
224
+ iconPrefix: { type: String, attribute: 'icon-prefix' },
225
+ /** Icon identifier in "family.name" format for suffix */
226
+ iconSuffix: { type: String, attribute: 'icon-suffix' },
227
+ /** Layout density: "compact", "comfortable", or "spacious". Unset behaves as "comfortable" */
228
+ density: { type: String, reflect: true },
229
+ /** Line clamping: "1", "2", "auto", or number. Unset behaves as "auto" */
230
+ lines: { type: String, reflect: true },
231
+ /** Vertical alignment of prefix and suffix: "center", "start", or "end". Unset behaves as "center" */
232
+ align: { type: String, reflect: true },
233
+ /** Text alignment of the body content: "left", "center", or "right". Unset uses --dile-list-item-text-align */
234
+ bodyAlign: { type: String, attribute: 'body-align', reflect: true },
235
+ /** Bottom separator: boolean attribute (full), "full", "inset" or "none". Unset means no divider */
236
+ divider: { type: String, reflect: true },
237
+ /** Whether item is interactive */
238
+ clickable: { type: Boolean, reflect: true },
239
+ /** Navigation URL */
240
+ href: { type: String, reflect: true },
241
+ /** Link target */
242
+ target: { type: String, reflect: true },
243
+ /** Disabled state */
244
+ disabled: { type: Boolean, reflect: true },
245
+ /** Selected state */
246
+ selected: { type: Boolean, reflect: true },
247
+ /** Expanded state */
248
+ expanded: { type: Boolean, reflect: true },
249
+ /** Value associated with the item */
250
+ value: { type: String, reflect: true },
251
+ /** Tabindex for roving tabindex control */
252
+ itemTabindex: { type: Number, attribute: 'item-tabindex' },
253
+ /** Mirrors the host role attribute so the template reacts to role changes */
254
+ _hostRole: { attribute: 'role' },
255
+ /** Internal slot states */
256
+ _hasPrefixSlot: { state: true },
257
+ _hasSuffixSlot: { state: true },
258
+ };
259
+ }
260
+
261
+ constructor() {
262
+ super();
263
+ this.clickable = false;
264
+ this.disabled = false;
265
+ this.selected = false;
266
+ this.itemTabindex = 0;
267
+ this._hasPrefixSlot = false;
268
+ this._hasSuffixSlot = false;
269
+ this._managesHostAria = false;
270
+
271
+ this._handleKeyDown = this._handleKeyDown.bind(this);
272
+ this._handleKeyUp = this._handleKeyUp.bind(this);
273
+ this.addEventListener('keydown', this._handleKeyDown);
274
+ this.addEventListener('keyup', this._handleKeyUp);
275
+ }
276
+
277
+ connectedCallback() {
278
+ super.connectedCallback();
279
+ if (!this.hasAttribute('role')) {
280
+ this.setAttribute('role', 'listitem');
281
+ }
282
+ this._checkLightDomSlots();
283
+ }
284
+
285
+ updated(changedProperties) {
286
+ super.updated(changedProperties);
287
+ if (changedProperties.has('lines')) {
288
+ if (this.lines && this.lines !== 'auto' && this.lines !== '1' && this.lines !== '2') {
289
+ this.style.setProperty('--_lines', String(this.lines));
290
+ } else {
291
+ this.style.removeProperty('--_lines');
292
+ }
293
+ }
294
+ this._syncHostAria();
295
+ }
296
+
297
+ /** When the host acts as an option / menuitem it is the focusable, ARIA-bearing element. */
298
+ _isHostFocusMode() {
299
+ return HOST_FOCUS_ROLES.includes(this._hostRole);
300
+ }
301
+
302
+ _isInteractive() {
303
+ return Boolean(this.clickable || this.href || this._isHostFocusMode());
304
+ }
305
+
306
+ _computedTabindex() {
307
+ if (this.disabled) return -1;
308
+ return this.itemTabindex !== undefined && this.itemTabindex !== null ? this.itemTabindex : 0;
309
+ }
310
+
311
+ _expandedValue() {
312
+ return this.expanded !== undefined ? String(Boolean(this.expanded)) : undefined;
313
+ }
314
+
315
+ _syncHostAria() {
316
+ if (this._isHostFocusMode()) {
317
+ this.setAttribute('tabindex', String(this._computedTabindex()));
318
+ if (this._hostRole === 'option') {
319
+ this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
320
+ } else {
321
+ this.removeAttribute('aria-selected');
322
+ }
323
+ this._toggleHostAttribute('aria-disabled', this.disabled ? 'true' : undefined);
324
+ this._toggleHostAttribute('aria-expanded', this._expandedValue());
325
+ this._managesHostAria = true;
326
+ } else if (this._managesHostAria) {
327
+ ['tabindex', 'aria-selected', 'aria-disabled', 'aria-expanded'].forEach((attr) => this.removeAttribute(attr));
328
+ this._managesHostAria = false;
329
+ }
330
+ }
331
+
332
+ _toggleHostAttribute(name, value) {
333
+ if (value === undefined) {
334
+ this.removeAttribute(name);
335
+ } else {
336
+ this.setAttribute(name, value);
337
+ }
338
+ }
339
+
340
+ _checkLightDomSlots() {
341
+ this._hasPrefixSlot = Boolean(this.querySelector(':scope > [slot="prefix"], :scope > [slot="leading"]'));
342
+ this._hasSuffixSlot = Boolean(this.querySelector(':scope > [slot="suffix"], :scope > [slot="trailing"]'));
343
+ }
344
+
345
+ _handleSlotChange() {
346
+ this._checkLightDomSlots();
347
+ }
348
+
349
+ focus(options) {
350
+ if (this._isHostFocusMode()) {
351
+ super.focus(options);
352
+ return;
353
+ }
354
+ const target = this.shadowRoot?.querySelector('.item');
355
+ if (target) {
356
+ target.focus(options);
357
+ } else {
358
+ super.focus(options);
359
+ }
360
+ }
361
+
362
+ render() {
363
+ const hasPrefix = Boolean(this._hasPrefixSlot || this.iconPrefix);
364
+ const hasSuffix = Boolean(this._hasSuffixSlot || this.iconSuffix);
365
+ const hostFocus = this._isHostFocusMode();
366
+ // The inner element carries focus and ARIA state unless the host is an option / menuitem.
367
+ const innerFocusable = this._isInteractive() && !hostFocus;
368
+ const tabindex = this._computedTabindex();
369
+ const innerDisabled = ifDefined(innerFocusable && this.disabled ? 'true' : undefined);
370
+ const innerCurrent = ifDefined(innerFocusable && this.selected ? 'true' : undefined);
371
+ const innerExpanded = ifDefined(innerFocusable ? this._expandedValue() : undefined);
372
+
373
+ const contentTemplate = html`
374
+ <div class="prefix" part="prefix" ?hidden="${!hasPrefix}">
375
+ <slot name="prefix" @slotchange="${this._handleSlotChange}"><slot name="leading" @slotchange="${this._handleSlotChange}">${this.iconPrefix ? html`<dile-iconlib icon="${this.iconPrefix}" aria-hidden="true"></dile-iconlib>` : ''}</slot></slot>
376
+ </div>
377
+ <div class="content" part="content">
378
+ <div class="body" part="body">
379
+ <slot></slot>
380
+ </div>
381
+ <div class="suffix" part="suffix" ?hidden="${!hasSuffix}">
382
+ <slot name="suffix" @slotchange="${this._handleSlotChange}"><slot name="trailing" @slotchange="${this._handleSlotChange}">${this.iconSuffix ? html`<dile-iconlib icon="${this.iconSuffix}" aria-hidden="true"></dile-iconlib>` : ''}</slot></slot>
383
+ </div>
384
+ </div>
385
+ `;
386
+
387
+ if (this.href) {
388
+ return html`
389
+ <a
390
+ class="item"
391
+ part="item"
392
+ href="${ifDefined(this.disabled ? undefined : this.href)}"
393
+ target="${ifDefined(this.target || undefined)}"
394
+ rel="${ifDefined(this.target === '_blank' ? 'noopener noreferrer' : undefined)}"
395
+ tabindex="${hostFocus ? -1 : tabindex}"
396
+ aria-disabled="${innerDisabled}"
397
+ aria-current="${innerCurrent}"
398
+ aria-expanded="${innerExpanded}"
399
+ @click="${this._handleItemClick}"
400
+ >${contentTemplate}</a>
401
+ `;
402
+ }
403
+
404
+ return html`
405
+ <div
406
+ class="item"
407
+ part="item"
408
+ role="${ifDefined(innerFocusable ? 'button' : undefined)}"
409
+ tabindex="${ifDefined(innerFocusable ? tabindex : undefined)}"
410
+ aria-disabled="${innerDisabled}"
411
+ aria-current="${innerCurrent}"
412
+ aria-expanded="${innerExpanded}"
413
+ @click="${this._handleItemClick}"
414
+ >${contentTemplate}</div>
415
+ `;
416
+ }
417
+
418
+ /**
419
+ * Returns true when the event started on an interactive control placed inside the item
420
+ * (slotted buttons, inputs, links, nested list items...). Those controls own their events.
421
+ */
422
+ _isFromNestedControl(e) {
423
+ const stop = this.shadowRoot.querySelector('.item');
424
+ for (const el of e.composedPath()) {
425
+ if (el === stop || el === this) return false;
426
+ if (!(el instanceof Element)) continue;
427
+ const tag = el.localName;
428
+ const role = el.getAttribute('role');
429
+ if (
430
+ INTERACTIVE_TAGS.includes(tag) ||
431
+ INTERACTIVE_ROLES.includes(role) ||
432
+ el.isContentEditable ||
433
+ (el.hasAttribute('tabindex') && el.tabIndex >= 0)
434
+ ) {
435
+ return true;
436
+ }
437
+ }
438
+ return false;
439
+ }
440
+
441
+ /** True when the event originated on the item's own focus target (host or inner .item). */
442
+ _isOwnFocusTarget(e) {
443
+ const origin = e.composedPath()[0];
444
+ return origin === this || origin === this.shadowRoot?.querySelector('.item');
445
+ }
446
+
447
+ _handleItemClick(e) {
448
+ if (!this._isInteractive()) return;
449
+ if (this.disabled) {
450
+ e.preventDefault();
451
+ e.stopPropagation();
452
+ return;
453
+ }
454
+ if (this._isFromNestedControl(e)) return;
455
+ this._dispatchItemClick();
456
+ }
457
+
458
+ _dispatchItemClick() {
459
+ this.dispatchEvent(new CustomEvent('dile-list-item-click', {
460
+ bubbles: true,
461
+ composed: true,
462
+ detail: {
463
+ item: this,
464
+ value: this.value,
465
+ href: this.href,
466
+ selected: this.selected,
467
+ }
468
+ }));
469
+ }
470
+
471
+ _handleKeyDown(e) {
472
+ if (this.disabled || !this._isInteractive() || !this._isOwnFocusTarget(e)) return;
473
+ if (e.key === 'Enter') {
474
+ if (!this.href) {
475
+ e.preventDefault();
476
+ this._dispatchItemClick();
477
+ } else if (e.composedPath()[0] === this) {
478
+ // Host has focus (option / menuitem mode): the native anchor won't receive the Enter key.
479
+ e.preventDefault();
480
+ this.shadowRoot.querySelector('a')?.click();
481
+ }
482
+ } else if (e.key === ' ') {
483
+ // Prevent the page from scrolling; activation happens on keyup (button behaviour).
484
+ e.preventDefault();
485
+ }
486
+ }
487
+
488
+ _handleKeyUp(e) {
489
+ if (this.disabled || !this._isInteractive() || !this._isOwnFocusTarget(e)) return;
490
+ if (e.key === ' ') {
491
+ e.preventDefault();
492
+ if (this.href) {
493
+ this.shadowRoot.querySelector('a')?.click();
494
+ } else {
495
+ this._dispatchItemClick();
496
+ }
497
+ }
498
+ }
499
+ }
@@ -97,6 +97,8 @@ export class DileSelect extends DileEmmitChange(LitElement) {
97
97
  static get properties() {
98
98
  return {
99
99
  label: { type: String },
100
+ /** Accessible name of the slotted select when there is no visible label. With label, the label text is used */
101
+ accessibleLabel: { type: String },
100
102
  value: { type: String },
101
103
  name: { type: String, reflect: true },
102
104
  disabled: { type: Boolean },
@@ -114,7 +116,7 @@ export class DileSelect extends DileEmmitChange(LitElement) {
114
116
  return html`
115
117
  <div>
116
118
  ${this.label
117
- ? html`<label for="${this.selectId}">${this.label}</label>`
119
+ ? html`<label for="${this.selectId}" @click="${this._focusSelect}">${this.label}</label>`
118
120
  : ""
119
121
  }
120
122
  <div class="select-wrapper">
@@ -159,6 +161,11 @@ export class DileSelect extends DileEmmitChange(LitElement) {
159
161
  if (!this.elselect.id) {
160
162
  this.elselect.id = this.selectId;
161
163
  }
164
+ // aria-label / aria-description set by the author on the slotted select always win
165
+ if (this._authorAriaLabel === undefined) {
166
+ this._authorAriaLabel = this.elselect.hasAttribute("aria-label");
167
+ this._authorAriaDescription = this.elselect.hasAttribute("aria-description");
168
+ }
162
169
  this.elselect.addEventListener("change", this.changeHandler);
163
170
  this.updateSelectA11y();
164
171
  }
@@ -194,7 +201,8 @@ export class DileSelect extends DileEmmitChange(LitElement) {
194
201
  this.elselect.classList.toggle("disabled", this.disabled);
195
202
  this.elselect.setAttribute("aria-disabled", this.disabled);
196
203
  }
197
- if(changedProperties.has("errored") || changedProperties.has("message")) {
204
+ if(changedProperties.has("errored") || changedProperties.has("message")
205
+ || changedProperties.has("label") || changedProperties.has("accessibleLabel")) {
198
206
  this.updateSelectA11y();
199
207
  }
200
208
  }
@@ -205,18 +213,39 @@ export class DileSelect extends DileEmmitChange(LitElement) {
205
213
  // Set aria-invalid when errored
206
214
  this.elselect.setAttribute("aria-invalid", this.errored);
207
215
 
208
- // Set aria-describedby when message exists
209
- const messageId = this.message ? `${this.selectId}-message` : null;
210
- if (messageId) {
211
- this.elselect.setAttribute("aria-describedby", messageId);
212
- } else {
213
- this.elselect.removeAttribute("aria-describedby");
216
+ // The message lives in the shadow DOM and the select in the light DOM, so an
217
+ // aria-describedby id reference cannot cross the boundary: use aria-description.
218
+ // aria-describedby is left to the author (e.g. help text in the light DOM).
219
+ if (!this._authorAriaDescription) {
220
+ if (this.message) {
221
+ this.elselect.setAttribute("aria-description", this.message);
222
+ } else {
223
+ this.elselect.removeAttribute("aria-description");
224
+ }
214
225
  }
215
226
 
227
+ // Accessible name. The <label for> lives in the shadow DOM and the select in the
228
+ // light DOM, so "for" cannot cross the boundary: name the select via aria-label.
229
+ if (!this._authorAriaLabel) {
230
+ const accessibleName = this.label || this.accessibleLabel;
231
+ if (accessibleName) {
232
+ this.elselect.setAttribute("aria-label", accessibleName);
233
+ } else {
234
+ this.elselect.removeAttribute("aria-label");
235
+ }
236
+ }
237
+
216
238
  // Update visual state
217
239
  this.elselect.classList.toggle("errored", this.errored);
218
240
  }
219
241
 
242
+ // <label for> cannot reach the slotted select, so replicate the native label click
243
+ _focusSelect() {
244
+ if (this.elselect && !this.disabled) {
245
+ this.elselect.focus();
246
+ }
247
+ }
248
+
220
249
  firstUpdated() {
221
250
  this.quiet = this.quietOnStart;
222
251
  if(this.value) {
@@ -150,6 +150,11 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
150
150
  .option[aria-selected="true"] {
151
151
  font-weight: var(--dile-select-overlay-selected-font-weight, 600);
152
152
  }
153
+ .option-content {
154
+ display: block;
155
+ flex: 1 1 auto;
156
+ min-width: 0;
157
+ }
153
158
  @media (forced-colors: active) {
154
159
  #trigger {
155
160
  border-width: var(--dile-input-border-width, 1px);
@@ -172,6 +177,8 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
172
177
  static get properties() {
173
178
  return {
174
179
  label: { type: String },
180
+ /** Accessible name of the combobox trigger and the listbox, applied as aria-label only when there is no visible label */
181
+ accessibleLabel: { type: String },
175
182
  value: { type: String },
176
183
  name: { type: String, reflect: true },
177
184
  disabled: { type: Boolean },
@@ -185,6 +192,12 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
185
192
  /** Suppresses the visible trigger box, letting a parent component drive open()/close()
186
193
  * and forward keyboard events via handleExternalKeydown() while supplying its own visible control. */
187
194
  hideTrigger: { type: Boolean, reflect: true },
195
+ /** Optional custom renderer for the content of each option in the popup:
196
+ * `(option, index) => TemplateResult | string`, where `option` is `{ value, text, icon, element }`
197
+ * and `element` is the native <option> (useful to read its dataset). The <li role="option">
198
+ * itself (ids, aria-selected, highlight, click) is still managed by the component, and the
199
+ * trigger, typeahead, accessible name and highlightedText keep using the plain option text. */
200
+ optionRenderer: { attribute: false },
188
201
  _activeIndex: { state: true },
189
202
  /** Display text of the currently highlighted option, readable by an external composer. */
190
203
  highlightedText: { state: true },
@@ -218,7 +231,7 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
218
231
 
219
232
  get options() {
220
233
  return this.elselect
221
- ? Array.from(this.elselect.options).map(o => ({ value: o.value, text: o.textContent, icon: o.dataset.icon || this.elselect.dataset.icon }))
234
+ ? Array.from(this.elselect.options).map(o => ({ value: o.value, text: o.textContent, icon: o.dataset.icon || this.elselect.dataset.icon, element: o }))
222
235
  : [];
223
236
  }
224
237
 
@@ -325,6 +338,11 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
325
338
  `;
326
339
  }
327
340
 
341
+ /** aria-label for the trigger and the listbox: accessibleLabel, only when there is no visible label */
342
+ get controlAriaLabel() {
343
+ return !this.label && this.accessibleLabel ? this.accessibleLabel : undefined;
344
+ }
345
+
328
346
  get triggerTemplate() {
329
347
  const selected = this.selectedOption;
330
348
  return html`
@@ -332,6 +350,7 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
332
350
  type="button"
333
351
  id="trigger"
334
352
  role="combobox"
353
+ aria-label="${ifDefined(this.controlAriaLabel)}"
335
354
  aria-haspopup="listbox"
336
355
  aria-expanded="${this.opened}"
337
356
  aria-controls="overlay"
@@ -360,21 +379,36 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
360
379
  get overlayTemplate() {
361
380
  const options = this.options;
362
381
  return html`
363
- <ul id="overlay" class="${this._overlayClass}" role="listbox">
364
- ${options.map((opt, index) => html`
382
+ <ul id="overlay" class="${this._overlayClass}" role="listbox" aria-label="${ifDefined(this.controlAriaLabel)}">
383
+ ${options.map((opt, index) => {
384
+ const highlighted = index === this._activeIndex;
385
+ const selected = opt.value === (this.value ?? '');
386
+ const parts = ['option', highlighted ? 'option-highlighted' : '', selected ? 'option-selected' : ''].filter(Boolean).join(' ');
387
+ return html`
365
388
  <li
366
389
  id="${this._optionId(index)}"
367
390
  role="option"
368
- class="option ${index === this._activeIndex ? 'highlighted' : ''}"
369
- aria-selected="${opt.value === (this.value ?? '')}"
391
+ part="${parts}"
392
+ class="option ${highlighted ? 'highlighted' : ''}"
393
+ aria-selected="${selected}"
394
+ aria-label="${ifDefined(this.optionRenderer ? opt.text.trim() : undefined)}"
370
395
  @click="${() => this.selectOption(index)}"
371
396
  @mousemove="${() => this._setActiveIndex(index, false)}"
372
- >${opt.icon ? html`<span class="option-icon">${this.renderIconlibIcon(opt.icon)}</span>` : ''}${opt.text}</li>
373
- `)}
397
+ >${this.optionContentTemplate(opt, index)}</li>
398
+ `;
399
+ })}
374
400
  </ul>
375
401
  `;
376
402
  }
377
403
 
404
+ /** Content of each <li role="option">: optionRenderer's output when set, icon + text otherwise. */
405
+ optionContentTemplate(opt, index) {
406
+ if (typeof this.optionRenderer === 'function') {
407
+ return html`<span class="option-content" part="option-content">${this.optionRenderer(opt, index)}</span>`;
408
+ }
409
+ return html`${opt.icon ? html`<span class="option-icon">${this.renderIconlibIcon(opt.icon)}</span>` : ''}${opt.text}`;
410
+ }
411
+
378
412
  renderIconlibIcon(icon) {
379
413
  const dotIndex = icon.indexOf('.');
380
414
  if (dotIndex === -1) {