@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.
@@ -243,12 +243,7 @@ export class DileFloatingMenu extends DileOverlay(DileCloseOnEscPressed(LitEleme
243
243
  }
244
244
 
245
245
  _hideOverlay() {
246
- // Closed during the opening delay of DileOverlay, where its close() would be ignored.
247
- if (this._opening) {
248
- this.cancelDelay();
249
- this._opening = false;
250
- this._overlayClass = 'opened';
251
- }
246
+ // DileOverlay's close() also cancels an opening still in its delay.
252
247
  super.close();
253
248
  }
254
249
 
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
4
  import { labelStyles } from './label-styles.js';
4
5
  import { messageStyles } from './message-styles.js';
@@ -22,6 +23,9 @@ export class DileInput extends DileEmmitChange(LitElement) {
22
23
  /** Label to the element */
23
24
  label: { type: String },
24
25
 
26
+ /** Accessible name of the input, applied as aria-label only when there is no visible label */
27
+ accessibleLabel: { type: String },
28
+
25
29
  /** Input type */
26
30
  type: { type: String },
27
31
 
@@ -166,6 +170,7 @@ export class DileInput extends DileEmmitChange(LitElement) {
166
170
  type="${this.availableType(this.type)}"
167
171
  id="textField"
168
172
  name="${this.name}"
173
+ aria-label="${ifDefined(this.inputAriaLabel)}"
169
174
  placeholder="${this.placeholder}"
170
175
  ?disabled="${this.disabled}"
171
176
  ?readonly="${this.readonly}"
@@ -187,6 +192,11 @@ export class DileInput extends DileEmmitChange(LitElement) {
187
192
  `;
188
193
  }
189
194
 
195
+ /** aria-label for the inner input: accessibleLabel, only when there is no visible label */
196
+ get inputAriaLabel() {
197
+ return !this.label && this.accessibleLabel ? this.accessibleLabel : undefined;
198
+ }
199
+
190
200
  get messageTemplate() {
191
201
  return html`
192
202
  ${this.message
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
  import { DileInput } from '../../input/index.js';
3
4
 
4
5
  export class DileInputContact extends DileInput {
@@ -48,6 +49,7 @@ export class DileInputContact extends DileInput {
48
49
  type="${this.availableType(this.type)}"
49
50
  id="textField"
50
51
  name="${this.name}"
52
+ aria-label="${ifDefined(this.inputAriaLabel)}"
51
53
  placeholder="${this.placeholder}"
52
54
  ?disabled="${this.disabled}"
53
55
  ?readonly="${this.readonly}"
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
  import { DileInput } from '../../input/index.js';
3
4
  import '@dile/iconlib/dile-iconlib.js';
4
5
  import '@dile/ui/components/tooltip/tooltip.js';
@@ -100,6 +101,7 @@ export class DileInputIcon extends DileInput {
100
101
  type="${this.availableType(this.type)}"
101
102
  id="textField"
102
103
  name="${this.name}"
104
+ aria-label="${ifDefined(this.inputAriaLabel)}"
103
105
  placeholder="${this.placeholder}"
104
106
  ?disabled="${this.disabled}"
105
107
  ?readonly="${this.readonly}"
@@ -0,0 +1,2 @@
1
+ export { DileList } from './src/DileList.js';
2
+ export { DileListItem } from './src/DileListItem.js';
@@ -0,0 +1,5 @@
1
+ import { DileListItem } from './src/DileListItem.js';
2
+
3
+ if (!customElements.get('dile-list-item')) {
4
+ customElements.define('dile-list-item', DileListItem);
5
+ }
@@ -0,0 +1,6 @@
1
+ import { DileList } from './src/DileList.js';
2
+
3
+ if (!customElements.get('dile-list')) {
4
+ customElements.define('dile-list', DileList);
5
+ }
6
+
@@ -0,0 +1,606 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import '../../spinner/spinner.js';
3
+
4
+ const HOST_FOCUS_ROLES = ['option', 'menuitem'];
5
+
6
+ export class DileList extends LitElement {
7
+ static get styles() {
8
+ return css`
9
+ :host {
10
+ display: block;
11
+ box-sizing: border-box;
12
+ background-color: var(--dile-list-background-color, var(--dile-surface-color, #ffffff));
13
+ border: var(--dile-list-border, 1px solid #e2e8f0);
14
+ border-radius: var(--dile-list-border-radius, 8px);
15
+ box-shadow: var(--dile-list-box-shadow, none);
16
+ overflow: hidden;
17
+ width: var(--dile-list-width, 100%);
18
+ max-width: var(--dile-list-max-width, none);
19
+ position: relative;
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ :host([borderless]) {
27
+ border: none;
28
+ border-radius: 0;
29
+ box-shadow: none;
30
+ background-color: transparent;
31
+ }
32
+
33
+ :host([disabled]) {
34
+ opacity: var(--dile-list-disabled-opacity, 0.6);
35
+ pointer-events: none;
36
+ }
37
+
38
+ :host([loading]) {
39
+ cursor: wait;
40
+ }
41
+
42
+ .list-container {
43
+ display: flex;
44
+ flex-direction: column;
45
+ width: 100%;
46
+ }
47
+
48
+ /* Automatically remove bottom border / divider on the last slotted list item */
49
+ ::slotted(dile-list-item:last-of-type) {
50
+ border-bottom: none !important;
51
+ --dile-list-item-divider-width: 0px !important;
52
+ }
53
+
54
+ ::slotted([divider]:last-of-type) {
55
+ border-bottom: none !important;
56
+ --dile-list-item-divider-width: 0px !important;
57
+ }
58
+
59
+ .empty-container {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ padding: var(--dile-list-empty-padding, 2rem 1rem);
64
+ color: var(--dile-list-empty-color, #64748b);
65
+ font-size: var(--dile-list-empty-font-size, 0.9rem);
66
+ text-align: center;
67
+ }
68
+
69
+ .empty-container[hidden] {
70
+ display: none !important;
71
+ }
72
+
73
+ .loading-overlay {
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ padding: var(--dile-list-loading-padding, 2rem 1rem);
78
+ background-color: var(--dile-list-loading-background-color, rgba(255, 255, 255, 0.8));
79
+ color: var(--dile-list-loading-color, #64748b);
80
+ }
81
+
82
+ /* With items, the loader floats over them. Without items it takes its own space. */
83
+ .loading-overlay.floating {
84
+ position: absolute;
85
+ top: 0;
86
+ left: 0;
87
+ right: 0;
88
+ bottom: 0;
89
+ z-index: 10;
90
+ }
91
+
92
+ .loading-overlay[hidden] {
93
+ display: none !important;
94
+ }
95
+ `;
96
+ }
97
+
98
+ static get properties() {
99
+ return {
100
+ /** Selection mode: "none", "single", or "multiple" */
101
+ selection: { type: String, reflect: true },
102
+ /** Selected value: string for single selection, array of strings for multiple */
103
+ value: {
104
+ type: String,
105
+ converter: {
106
+ fromAttribute(attr) {
107
+ if (!attr) return attr;
108
+ if (attr.startsWith('[') || attr.startsWith('{')) {
109
+ try {
110
+ return JSON.parse(attr);
111
+ } catch (_) {
112
+ return attr;
113
+ }
114
+ }
115
+ return attr;
116
+ },
117
+ toAttribute(val) {
118
+ if (val === null || val === undefined) return null;
119
+ if (Array.isArray(val) || typeof val === 'object') {
120
+ return JSON.stringify(val);
121
+ }
122
+ return String(val);
123
+ },
124
+ },
125
+ },
126
+ /** Whether all items in the list should be clickable */
127
+ clickable: { type: Boolean, reflect: true },
128
+ /** Layout density propagated to items: "compact", "comfortable", or "spacious" */
129
+ density: { type: String, reflect: true },
130
+ /** Divider style propagated to items: boolean attribute (full), "full", "inset" or "none" */
131
+ divider: { type: String, reflect: true },
132
+ /** Text clamping lines propagated to items: "1", "2", "auto" or number */
133
+ lines: { type: String, reflect: true },
134
+ /** Vertical alignment propagated to items: "start", "center", "end" */
135
+ align: { type: String, reflect: true },
136
+ /** Horizontal body alignment propagated to items: "left", "center", "right" */
137
+ bodyAlign: { type: String, attribute: 'body-align', reflect: true },
138
+ /** Prefix icon ("family.name") propagated to items */
139
+ iconPrefix: { type: String, attribute: 'icon-prefix' },
140
+ /** Suffix icon ("family.name") propagated to items */
141
+ iconSuffix: { type: String, attribute: 'icon-suffix' },
142
+ /** Disabled state for the entire list */
143
+ disabled: { type: Boolean, reflect: true },
144
+ /** Loading state that shows spinner / overlay */
145
+ loading: { type: Boolean, reflect: true },
146
+ /** Text for default empty state fallback */
147
+ emptyText: { type: String, attribute: 'empty-text' },
148
+ /**
149
+ * Whether arrow key keyboard navigation with roving tabindex is enabled.
150
+ * Defaults to true; use navigable="false" in HTML to disable it.
151
+ */
152
+ navigable: {
153
+ type: Boolean,
154
+ converter: {
155
+ fromAttribute: (attr) => attr !== 'false',
156
+ },
157
+ },
158
+ /** Whether navigating with arrow keys automatically selects the item in single selection */
159
+ selectOnFocus: { type: Boolean, attribute: 'select-on-focus' },
160
+ /** Removes the outer border and border-radius */
161
+ borderless: { type: Boolean, reflect: true },
162
+ /** Internal flag for empty state */
163
+ _isEmpty: { state: true },
164
+ };
165
+ }
166
+
167
+ constructor() {
168
+ super();
169
+ this.selection = 'none';
170
+ this.value = undefined;
171
+ this.clickable = false;
172
+ this.disabled = false;
173
+ this.loading = false;
174
+ this.emptyText = '';
175
+ this.navigable = true;
176
+ this.selectOnFocus = false;
177
+ this.borderless = false;
178
+ this._isEmpty = false;
179
+ this._focusedIndex = -1;
180
+ /** Values the list has imposed on each item, to be able to restore them later */
181
+ this._cascadeRecords = new WeakMap();
182
+
183
+ this._onItemClick = this._onItemClick.bind(this);
184
+ this._onKeydown = this._onKeydown.bind(this);
185
+ this._onFocusIn = this._onFocusIn.bind(this);
186
+ }
187
+
188
+ connectedCallback() {
189
+ super.connectedCallback();
190
+ if (!this.hasAttribute('role')) {
191
+ this.setAttribute('role', this.selection !== 'none' ? 'listbox' : 'list');
192
+ }
193
+ if (this.selection === 'multiple') {
194
+ this.setAttribute('aria-multiselectable', 'true');
195
+ }
196
+ this._isEmpty = this.getItems().length === 0;
197
+ this.addEventListener('dile-list-item-click', this._onItemClick);
198
+ this.addEventListener('keydown', this._onKeydown);
199
+ this.addEventListener('focusin', this._onFocusIn);
200
+ }
201
+
202
+ disconnectedCallback() {
203
+ super.disconnectedCallback();
204
+ this.removeEventListener('dile-list-item-click', this._onItemClick);
205
+ this.removeEventListener('keydown', this._onKeydown);
206
+ this.removeEventListener('focusin', this._onFocusIn);
207
+ }
208
+
209
+ updated(changedProperties) {
210
+ super.updated(changedProperties);
211
+
212
+ if (changedProperties.has('loading')) {
213
+ if (this.loading) {
214
+ this.setAttribute('aria-busy', 'true');
215
+ } else {
216
+ this.removeAttribute('aria-busy');
217
+ }
218
+ }
219
+
220
+ if (changedProperties.has('selection')) {
221
+ if (this.selection !== 'none') {
222
+ if (!this.hasAttribute('role') || this.getAttribute('role') === 'list') {
223
+ this.setAttribute('role', 'listbox');
224
+ }
225
+ if (this.selection === 'multiple') {
226
+ this.setAttribute('aria-multiselectable', 'true');
227
+ } else {
228
+ this.removeAttribute('aria-multiselectable');
229
+ }
230
+ } else {
231
+ if (!this.hasAttribute('role') || this.getAttribute('role') === 'listbox') {
232
+ this.setAttribute('role', 'list');
233
+ }
234
+ this.removeAttribute('aria-multiselectable');
235
+ }
236
+ this._syncAriaRoles();
237
+ }
238
+
239
+ if (
240
+ changedProperties.has('selection') ||
241
+ changedProperties.has('disabled') ||
242
+ changedProperties.has('clickable') ||
243
+ changedProperties.has('density') ||
244
+ changedProperties.has('divider') ||
245
+ changedProperties.has('lines') ||
246
+ changedProperties.has('align') ||
247
+ changedProperties.has('bodyAlign') ||
248
+ changedProperties.has('iconPrefix') ||
249
+ changedProperties.has('iconSuffix')
250
+ ) {
251
+ this._cascadeProperties();
252
+ }
253
+
254
+ if (changedProperties.has('value')) {
255
+ this._syncSelectionFromValue();
256
+ }
257
+
258
+ if (
259
+ changedProperties.has('navigable') ||
260
+ changedProperties.has('selection') ||
261
+ changedProperties.has('clickable') ||
262
+ changedProperties.has('disabled')
263
+ ) {
264
+ this._updateRovingTabindex();
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Returns the dile-list-item elements that are direct children of the list.
270
+ * Items of nested lists are not included.
271
+ * @returns {Array<HTMLElement>}
272
+ */
273
+ getItems() {
274
+ return Array.from(this.children).filter((el) => el.localName === 'dile-list-item');
275
+ }
276
+
277
+ _getItemValue(item, index) {
278
+ if (item.value !== undefined && item.value !== null && item.value !== '') {
279
+ return item.value;
280
+ }
281
+ if (item.id) {
282
+ return item.id;
283
+ }
284
+ return String(index);
285
+ }
286
+
287
+ /** Items that can receive focus: enabled and interactive (clickable, link, option or menuitem). */
288
+ _isFocusableItem(item) {
289
+ return (
290
+ !item.disabled &&
291
+ Boolean(item.clickable || item.href || HOST_FOCUS_ROLES.includes(item.getAttribute('role')))
292
+ );
293
+ }
294
+
295
+ _handleSlotChange() {
296
+ this._isEmpty = this.getItems().length === 0;
297
+ this._cascadeProperties();
298
+ this._syncAriaRoles();
299
+ this._syncSelectionFromValue();
300
+ this._updateRovingTabindex();
301
+ }
302
+
303
+ _cascadeProperties() {
304
+ const shouldBeClickable = this.clickable || this.selection !== 'none';
305
+ this.getItems().forEach((item) => {
306
+ // Behavioural properties: the list overrides the item while active.
307
+ this._cascadeProp(item, 'clickable', shouldBeClickable ? true : undefined, true);
308
+ this._cascadeProp(item, 'disabled', this.disabled ? true : undefined, true);
309
+ // Presentational properties: values defined on the item itself take precedence
310
+ // (an empty icon-prefix="" / icon-suffix="" on an item opts out of the list icon).
311
+ this._cascadeProp(item, 'density', this.density || undefined);
312
+ this._cascadeProp(item, 'divider', this.divider ?? undefined);
313
+ this._cascadeProp(item, 'lines', this.lines || undefined);
314
+ this._cascadeProp(item, 'align', this.align || undefined);
315
+ this._cascadeProp(item, 'bodyAlign', this.bodyAlign || undefined);
316
+ this._cascadeProp(item, 'iconPrefix', this.iconPrefix || undefined);
317
+ this._cascadeProp(item, 'iconSuffix', this.iconSuffix || undefined);
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Applies a list-level value to an item, remembering the item's original value so it
323
+ * can be restored when the list stops imposing it (value === undefined).
324
+ * @param {HTMLElement} item
325
+ * @param {string} prop
326
+ * @param {*} value value to impose, or undefined to release control
327
+ * @param {boolean} override whether the list value wins over a value defined on the item
328
+ */
329
+ _cascadeProp(item, prop, value, override = false) {
330
+ let record = this._cascadeRecords.get(item);
331
+ if (!record) {
332
+ record = {};
333
+ this._cascadeRecords.set(item, record);
334
+ }
335
+ let entry = record[prop];
336
+
337
+ // The item value was changed from outside since the list set it: the item takes control.
338
+ if (entry && item[prop] !== entry.value) {
339
+ delete record[prop];
340
+ entry = undefined;
341
+ }
342
+
343
+ if (value === undefined) {
344
+ if (entry) {
345
+ item[prop] = entry.original;
346
+ delete record[prop];
347
+ }
348
+ return;
349
+ }
350
+
351
+ if (!entry) {
352
+ const own = item[prop];
353
+ if (!override && own !== undefined && own !== null) return;
354
+ entry = record[prop] = { original: own };
355
+ }
356
+ entry.value = value;
357
+ if (item[prop] !== value) {
358
+ item[prop] = value;
359
+ }
360
+ }
361
+
362
+ _syncAriaRoles() {
363
+ const currentRole = this.getAttribute('role') || (this.selection !== 'none' ? 'listbox' : 'list');
364
+ let childRole = 'listitem';
365
+ if (currentRole === 'listbox') {
366
+ childRole = 'option';
367
+ } else if (currentRole === 'menu') {
368
+ childRole = 'menuitem';
369
+ }
370
+
371
+ this.getItems().forEach((item) => {
372
+ const itemRole = item.getAttribute('role');
373
+ if (!itemRole || itemRole === 'listitem' || itemRole === 'option' || itemRole === 'menuitem') {
374
+ if (itemRole !== childRole) {
375
+ item.setAttribute('role', childRole);
376
+ }
377
+ }
378
+ });
379
+ }
380
+
381
+ _syncSelectionFromValue() {
382
+ if (this.selection === 'none') return;
383
+ const items = this.getItems();
384
+
385
+ if (this.selection === 'single') {
386
+ if (this.value !== undefined && this.value !== null) {
387
+ items.forEach((item, idx) => {
388
+ const val = this._getItemValue(item, idx);
389
+ const isSelected = String(val) === String(this.value);
390
+ if (item.selected !== isSelected) {
391
+ item.selected = isSelected;
392
+ }
393
+ });
394
+ } else {
395
+ const selectedItem = items.find((i) => i.selected);
396
+ if (selectedItem) {
397
+ const newVal = this._getItemValue(selectedItem, items.indexOf(selectedItem));
398
+ if (this.value !== newVal) {
399
+ this.value = newVal;
400
+ }
401
+ }
402
+ }
403
+ } else if (this.selection === 'multiple') {
404
+ const valArr = Array.isArray(this.value)
405
+ ? this.value.map(String)
406
+ : typeof this.value === 'string' && this.value
407
+ ? this.value.split(',').map((s) => s.trim())
408
+ : null;
409
+
410
+ if (valArr !== null) {
411
+ items.forEach((item, idx) => {
412
+ const val = String(this._getItemValue(item, idx));
413
+ const isSelected = valArr.includes(val);
414
+ if (item.selected !== isSelected) {
415
+ item.selected = isSelected;
416
+ }
417
+ });
418
+ } else {
419
+ const selectedItems = items.filter((i) => i.selected);
420
+ if (selectedItems.length) {
421
+ this.value = selectedItems.map((item) => this._getItemValue(item, items.indexOf(item)));
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ _onItemClick(e) {
428
+ if (this.disabled || this.loading) return;
429
+ const item = e.detail?.item;
430
+ if (!item || item.disabled) return;
431
+
432
+ const items = this.getItems();
433
+ const idx = items.indexOf(item);
434
+ // Ignore clicks coming from items that don't belong to this list (e.g. nested lists)
435
+ if (idx === -1) return;
436
+
437
+ this._focusedIndex = idx;
438
+ this._updateRovingTabindex();
439
+
440
+ if (this.selection === 'single') {
441
+ this._selectSingle(item, idx);
442
+ } else if (this.selection === 'multiple') {
443
+ this._toggleMultiple(item);
444
+ }
445
+ }
446
+
447
+ _selectSingle(item, idx) {
448
+ const val = this._getItemValue(item, idx);
449
+ if (item.selected && this.value !== undefined && this.value !== null && String(this.value) === String(val)) {
450
+ return;
451
+ }
452
+
453
+ this.getItems().forEach((i) => {
454
+ i.selected = i === item;
455
+ });
456
+
457
+ this.value = val;
458
+ this._dispatchChangeEvent(item, [item]);
459
+ }
460
+
461
+ _toggleMultiple(item) {
462
+ item.selected = !item.selected;
463
+ const items = this.getItems();
464
+ const selectedItems = items.filter((i) => i.selected);
465
+ this.value = selectedItems.map((i) => this._getItemValue(i, items.indexOf(i)));
466
+ this._dispatchChangeEvent(item, selectedItems);
467
+ }
468
+
469
+ _dispatchChangeEvent(triggerItem, selectedItems) {
470
+ this.dispatchEvent(
471
+ new CustomEvent('dile-list-change', {
472
+ bubbles: true,
473
+ composed: true,
474
+ detail: {
475
+ value: this.value,
476
+ selectedItem: triggerItem,
477
+ selectedItems: selectedItems,
478
+ },
479
+ })
480
+ );
481
+ }
482
+
483
+ _onFocusIn(e) {
484
+ const items = this.getItems();
485
+ const idx = items.indexOf(e.target);
486
+ if (idx !== -1) {
487
+ this._focusedIndex = idx;
488
+ this._updateRovingTabindex();
489
+ }
490
+ }
491
+
492
+ _updateRovingTabindex() {
493
+ const items = this.getItems();
494
+ if (!items.length) return;
495
+
496
+ if (!this.navigable) {
497
+ items.forEach((item) => {
498
+ item.itemTabindex = 0;
499
+ });
500
+ return;
501
+ }
502
+
503
+ const focusable = items.filter((i) => this._isFocusableItem(i));
504
+ if (!focusable.length) return;
505
+
506
+ let active = items[this._focusedIndex];
507
+ if (!focusable.includes(active)) {
508
+ active = focusable.find((i) => i.selected) || focusable[0];
509
+ }
510
+
511
+ this._focusedIndex = items.indexOf(active);
512
+ items.forEach((item) => {
513
+ item.itemTabindex = item === active ? 0 : -1;
514
+ });
515
+ }
516
+
517
+ /** Returns the list item whose own focus target (host or inner element) originated the event. */
518
+ _getEventItem(e, items) {
519
+ const origin = e.composedPath()[0];
520
+ return items.find((item) => item === origin || item.shadowRoot?.contains(origin));
521
+ }
522
+
523
+ _onKeydown(e) {
524
+ if (this.disabled || this.loading || !this.navigable) return;
525
+ const navKeys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
526
+ if (!navKeys.includes(e.key)) return;
527
+
528
+ const allItems = this.getItems();
529
+ // Only handle keys pressed on an item itself, not on controls placed inside items.
530
+ const currentItem = this._getEventItem(e, allItems);
531
+ if (!currentItem) return;
532
+
533
+ const focusable = allItems.filter((i) => this._isFocusableItem(i));
534
+ if (!focusable.length) return;
535
+
536
+ e.preventDefault();
537
+ let currentIdx = focusable.indexOf(currentItem);
538
+ if (currentIdx === -1) currentIdx = 0;
539
+
540
+ let nextIdx;
541
+ switch (e.key) {
542
+ case 'ArrowDown':
543
+ nextIdx = (currentIdx + 1) % focusable.length;
544
+ break;
545
+ case 'ArrowUp':
546
+ nextIdx = (currentIdx - 1 + focusable.length) % focusable.length;
547
+ break;
548
+ case 'Home':
549
+ nextIdx = 0;
550
+ break;
551
+ case 'End':
552
+ nextIdx = focusable.length - 1;
553
+ break;
554
+ }
555
+
556
+ const nextItem = focusable[nextIdx];
557
+ if (nextItem) {
558
+ this._focusedIndex = allItems.indexOf(nextItem);
559
+ this._updateRovingTabindex();
560
+ nextItem.focus();
561
+
562
+ if (this.selection === 'single' && this.selectOnFocus) {
563
+ this._selectSingle(nextItem, this._focusedIndex);
564
+ }
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Programmatically selects an item by its value.
570
+ * @param {string} val
571
+ */
572
+ select(val) {
573
+ this.value = val;
574
+ this._syncSelectionFromValue();
575
+ }
576
+
577
+ /**
578
+ * Clears selection in the list.
579
+ */
580
+ clearSelection() {
581
+ this.value = this.selection === 'multiple' ? [] : undefined;
582
+ this.getItems().forEach((item) => {
583
+ item.selected = false;
584
+ });
585
+ }
586
+
587
+ render() {
588
+ return html`
589
+ <div class="list-container" part="container">
590
+ <slot @slotchange="${this._handleSlotChange}"></slot>
591
+ </div>
592
+
593
+ <div class="empty-container" part="empty" ?hidden="${!this._isEmpty || this.loading}">
594
+ <slot name="empty">
595
+ <span>${this.emptyText || 'No items available'}</span>
596
+ </slot>
597
+ </div>
598
+
599
+ <div class="loading-overlay ${this._isEmpty ? '' : 'floating'}" part="loading" ?hidden="${!this.loading}">
600
+ <slot name="loading">
601
+ <dile-spinner active></dile-spinner>
602
+ </slot>
603
+ </div>
604
+ `;
605
+ }
606
+ }