@dile/ui 3.14.0 → 3.15.0

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.
@@ -78,4 +78,35 @@ describe('dile-drop-file', () => {
78
78
  fileInput = el.shadowRoot.querySelector('input[type="file"]');
79
79
  expect(fileInput.getAttribute('capture')).toBe('user');
80
80
  });
81
+
82
+ it('emits dile-drop-file-changed event when file is selected', async () => {
83
+ const el = await renderDropFile('<dile-drop-file></dile-drop-file>');
84
+ let eventDetail = null;
85
+ el.addEventListener('dile-drop-file-changed', (e) => { eventDetail = e.detail; });
86
+
87
+ const fileInput = el.shadowRoot.querySelector('input[type="file"]');
88
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
89
+ Object.defineProperty(fileInput, 'files', {
90
+ value: [file],
91
+ writable: false,
92
+ });
93
+
94
+ fileInput.dispatchEvent(new Event('change', { bubbles: true }));
95
+ await el.updateComplete;
96
+
97
+ expect(eventDetail).toBeTruthy();
98
+ expect(eventDetail.file).toEqual(file);
99
+ });
100
+
101
+ it('emits dile-drop-file-changed event with null when file is cleared', async () => {
102
+ const el = await renderDropFile('<dile-drop-file></dile-drop-file>');
103
+ let eventDetail = null;
104
+ el.addEventListener('dile-drop-file-changed', (e) => { eventDetail = e.detail; });
105
+
106
+ el.clear();
107
+ await el.updateComplete;
108
+
109
+ expect(eventDetail).toBeTruthy();
110
+ expect(eventDetail.file).toBeNull();
111
+ });
81
112
  });
@@ -171,6 +171,7 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
171
171
  if (files.length > 0) {
172
172
  this.fileName = files[0].name;
173
173
  this.emmitChange();
174
+ this._emitDropFileChanged(files[0]);
174
175
  if(this._isValidExtension(this.fileName)) {
175
176
  this.message = this.firstMessage || '';
176
177
  this.errored = false;
@@ -210,6 +211,16 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
210
211
  }
211
212
  }
212
213
 
214
+ _emitDropFileChanged(file) {
215
+ this.dispatchEvent(new CustomEvent('dile-drop-file-changed', {
216
+ bubbles: true,
217
+ composed: true,
218
+ detail: {
219
+ file: file
220
+ }
221
+ }));
222
+ }
223
+
213
224
  openFileDialog() {
214
225
  this.fileInput.click();
215
226
  }
@@ -218,6 +229,7 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
218
229
  this.fileInput.value = "";
219
230
  this.fileName = "";
220
231
  this.internals.setFormValue(null);
232
+ this._emitDropFileChanged(null);
221
233
  this.emmitChange();
222
234
  }
223
235
 
@@ -31,6 +31,48 @@ describe('dile-input-search', () => {
31
31
  expect(input.getAttribute('data-form-type')).toBe('other');
32
32
  });
33
33
 
34
+ it('does not show an empty error box when errored without error slot content', async () => {
35
+ const el = await renderInputSearch('<dile-input-search errored></dile-input-search>');
36
+
37
+ const errorMessage = el.shadowRoot.getElementById('error-msg');
38
+ expect(errorMessage.hidden).toBe(true);
39
+ expect(errorMessage.getBoundingClientRect().height).toBe(0);
40
+ // Only the search box itself gets the bordered box styles
41
+ expect(getComputedStyle(errorMessage).borderTopStyle).toBe('none');
42
+ });
43
+
44
+ it('shows the error slot content when errored', async () => {
45
+ const el = await renderInputSearch('<dile-input-search errored><span slot="error">Wrong search</span></dile-input-search>');
46
+ await el.updateComplete;
47
+
48
+ const errorMessage = el.shadowRoot.getElementById('error-msg');
49
+ expect(errorMessage.hidden).toBe(false);
50
+ expect(el.shadowRoot.querySelector('input').getAttribute('aria-describedby')).toBe('error-msg');
51
+ });
52
+
53
+ it('labels the button in English by default, with configurable texts', async () => {
54
+ const el = await renderInputSearch('<dile-input-search></dile-input-search>');
55
+ const button = el.shadowRoot.querySelector('button');
56
+ expect(button.getAttribute('aria-label')).toBe('Search');
57
+ expect(el.shadowRoot.querySelector('input').getAttribute('aria-label')).toBe('Search');
58
+
59
+ el.value = 'lit';
60
+ await el.updateComplete;
61
+ expect(button.getAttribute('aria-label')).toBe('Clear search');
62
+
63
+ el.clearButtonLabel = 'Limpiar búsqueda';
64
+ await el.updateComplete;
65
+ expect(button.getAttribute('aria-label')).toBe('Limpiar búsqueda');
66
+ });
67
+
68
+ it('uses accessibleLabel as aria-label, falling back to name', async () => {
69
+ const named = await renderInputSearch('<dile-input-search name="Products"></dile-input-search>');
70
+ expect(named.shadowRoot.querySelector('input').getAttribute('aria-label')).toBe('Products');
71
+
72
+ const labelled = await renderInputSearch('<dile-input-search name="Products" accessibleLabel="Search products"></dile-input-search>');
73
+ expect(labelled.shadowRoot.querySelector('input').getAttribute('aria-label')).toBe('Search products');
74
+ });
75
+
34
76
  it('updates the value on input and dispatches dile-input-search after the delay', async () => {
35
77
  const el = await renderInputSearch('<dile-input-search delay="10"></dile-input-search>');
36
78
  const input = el.shadowRoot.querySelector('input');
@@ -8,7 +8,7 @@ export class DileInputSearch extends LitElement {
8
8
  :host {
9
9
  display: block;
10
10
  }
11
- div {
11
+ .search-box {
12
12
  display: flex;
13
13
  box-sizing: border-box;
14
14
  border-radius: var(--dile-input-border-radius, 5px);
@@ -17,7 +17,7 @@ export class DileInputSearch extends LitElement {
17
17
  width: var(--dile-input-width, 100%);
18
18
  background-color: var(--dile-input-background-color, #fff);
19
19
  }
20
- div:focus-within {
20
+ .search-box:focus-within {
21
21
  border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
22
22
  /* Outline adicional para mayor visibilidad */
23
23
  outline: 2px solid var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
@@ -25,6 +25,7 @@ export class DileInputSearch extends LitElement {
25
25
  }
26
26
  input {
27
27
  flex-grow: 1;
28
+ min-width: 0;
28
29
  border: none;
29
30
  color: var(--dile-input-color, #303030);
30
31
  font-size: var(--dile-input-font-size, 1em);
@@ -97,6 +98,14 @@ export class DileInputSearch extends LitElement {
97
98
  errored: { type: Boolean },
98
99
  /** Name for this input field */
99
100
  name: { type: String },
101
+ /** Whether there is content in the "error" slot */
102
+ _hasErrorContent: { state: true },
103
+ /** Accessible name of the input field, read by screen readers. Defaults to name, or to searchButtonLabel. */
104
+ accessibleLabel: { type: String },
105
+ /** Accessible name of the button when the field is empty */
106
+ searchButtonLabel: { type: String },
107
+ /** Accessible name of the button that clears the field */
108
+ clearButtonLabel: { type: String },
100
109
  };
101
110
  }
102
111
 
@@ -107,6 +116,9 @@ export class DileInputSearch extends LitElement {
107
116
  this.value = '';
108
117
  this.readOnly = false;
109
118
  this.errored = false;
119
+ this._hasErrorContent = false;
120
+ this.searchButtonLabel = 'Search';
121
+ this.clearButtonLabel = 'Clear search';
110
122
  }
111
123
 
112
124
  firstUpdated() {
@@ -114,12 +126,12 @@ export class DileInputSearch extends LitElement {
114
126
  }
115
127
 
116
128
  render() {
117
- const iconLabel = this.value.length
118
- ? 'Limpiar búsqueda'
119
- : 'Buscar';
129
+ const iconLabel = this.value.length
130
+ ? this.clearButtonLabel
131
+ : this.searchButtonLabel;
120
132
 
121
133
  return html`
122
- <div class="${this.errored ? "errored" : ""}">
134
+ <div class="search-box ${this.errored ? "errored" : ""}">
123
135
  <input
124
136
  id="elinput"
125
137
  type="search"
@@ -132,9 +144,9 @@ export class DileInputSearch extends LitElement {
132
144
  .value="${this.value}"
133
145
  ?disabled="${this.disabled}"
134
146
  ?readonly="${this.readOnly}"
135
- aria-label="${this.name || 'Búsqueda'}"
147
+ aria-label="${this.accessibleLabel || this.name || this.searchButtonLabel}"
136
148
  ?aria-invalid="${this.errored}"
137
- aria-describedby="${this.errored ? 'error-msg' : null}"
149
+ aria-describedby="${this.showErrorMessage ? 'error-msg' : null}"
138
150
  >
139
151
  <button
140
152
  @click=${this.iconClick}
@@ -150,14 +162,21 @@ export class DileInputSearch extends LitElement {
150
162
  ></dile-icon>
151
163
  </button>
152
164
  </div>
153
- ${this.errored ? html`
154
- <div id="error-msg" class="error-message" role="alert">
155
- <slot name="error"></slot>
156
- </div>
157
- ` : ''}
165
+ <div id="error-msg" class="error-message" role="alert" ?hidden=${!this.showErrorMessage}>
166
+ <slot name="error" @slotchange=${this.errorSlotChanged}></slot>
167
+ </div>
158
168
  `;
159
169
  }
160
170
 
171
+ /** The error message is only shown in the errored state, when there is an "error" slot content */
172
+ get showErrorMessage() {
173
+ return this.errored && this._hasErrorContent;
174
+ }
175
+
176
+ errorSlotChanged(e) {
177
+ this._hasErrorContent = e.target.assignedNodes({ flatten: true }).length > 0;
178
+ }
179
+
161
180
  inputHandler(e) {
162
181
  this.value = e.target.value;
163
182
  if (this.timeout) {
@@ -17,8 +17,8 @@ describe('dile-order-switch', () => {
17
17
  const el = await renderOrderSwitch('<dile-order-switch label="Name"></dile-order-switch>');
18
18
 
19
19
  expect(el.value).toBe('asc');
20
- expect(el.shadowRoot.querySelector('dile-icon')).toBeTruthy();
21
- expect(el.shadowRoot.querySelector('span').textContent.trim()).toBe('Name');
20
+ expect(el.shadowRoot.querySelector('dile-button-icon')).toBeTruthy();
21
+ expect(el.shadowRoot.querySelector('a').textContent.trim()).toBe('Name');
22
22
  });
23
23
 
24
24
  it('toggles between asc and desc', async () => {
@@ -35,7 +35,7 @@ describe('dile-order-switch', () => {
35
35
  let receivedDetail = null;
36
36
  el.addEventListener('element-changed', (e) => { receivedDetail = e.detail; });
37
37
 
38
- el.shadowRoot.querySelector('span').click();
38
+ el.shadowRoot.querySelector('a').click();
39
39
  await el.updateComplete;
40
40
 
41
41
  expect(el.value).toBe('asc'); // unchanged
@@ -48,7 +48,7 @@ describe('dile-order-switch', () => {
48
48
  let receivedDetail = null;
49
49
  el.addEventListener('element-changed', (e) => { receivedDetail = e.detail; });
50
50
 
51
- el.shadowRoot.querySelector('span').click();
51
+ el.shadowRoot.querySelector('a').click();
52
52
  await el.updateComplete;
53
53
 
54
54
  expect(el.value).toBe('desc'); // toggled
@@ -2,4 +2,6 @@ export { DileSelect } from './src/DileSelect.js';
2
2
  export { DileSelectAjax } from './src/DileSelectAjax.js';
3
3
  export { DileSelectBoolean } from './src/DileSelectBoolean.js';
4
4
  export { DileSelectOverlay } from './src/DileSelectOverlay.js';
5
- export { DileSelectAjaxOverlay } from './src/DileSelectAjaxOverlay.js';
5
+ export { DileSelectAjaxOverlay } from './src/DileSelectAjaxOverlay.js';
6
+ export { DileSelectSearch } from './src/DileSelectSearch.js';
7
+ export { DileSelectSearchableBase } from './src/DileSelectSearchableBase.js';
@@ -321,6 +321,22 @@ describe('dile-select-overlay', () => {
321
321
  });
322
322
 
323
323
  describe('Positioning', () => {
324
+ it('can open a popup wider than a narrow trigger with --dile-select-overlay-min-width', async () => {
325
+ const el = await renderSelectOverlay(`
326
+ <dile-select-overlay style="width: 80px; --dile-select-overlay-min-width: 200px;">
327
+ <select slot="select">
328
+ <option value="1">A long option text</option>
329
+ </select>
330
+ </dile-select-overlay>
331
+ `);
332
+
333
+ await open(el);
334
+
335
+ const overlay = el.shadowRoot.getElementById('overlay');
336
+ expect(overlay.getBoundingClientRect().width).toBe(200);
337
+ expect(el.shadowRoot.getElementById('trigger').getBoundingClientRect().width).toBe(80);
338
+ });
339
+
324
340
  it('keeps the same moveTop-tightened gap when flipping above the trigger as when sitting below it', async () => {
325
341
  // Regression test: moveTop used to only tighten the gap for the normal (below) position.
326
342
  // The flip-to-above fallback used a fixed 10px margin unrelated to moveTop, so a popup
@@ -0,0 +1,6 @@
1
+ import './select-overlay.js'
2
+ import { DileSelectSearch } from "./src/DileSelectSearch.js";
3
+
4
+ if (!customElements.get("dile-select-search")) {
5
+ window.customElements.define("dile-select-search", DileSelectSearch);
6
+ }
@@ -0,0 +1,318 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './select-search.js';
3
+
4
+ describe('dile-select-search', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ function wait(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ const OPTIONS = `
14
+ <select slot="select">
15
+ <option value="ES">España (+34)</option>
16
+ <option value="PT">Portugal (+351)</option>
17
+ <option value="BR">Brasil (+55)</option>
18
+ <option value="FR">France (+33)</option>
19
+ </select>
20
+ `;
21
+
22
+ async function renderSelectSearch(attributes = '', options = OPTIONS) {
23
+ document.body.innerHTML = `<dile-select-search ${attributes}>${options}</dile-select-search>`;
24
+ const el = document.body.querySelector('dile-select-search');
25
+ await el.updateComplete;
26
+ return el;
27
+ }
28
+
29
+ async function settle(el) {
30
+ await wait(20);
31
+ await el.updateComplete;
32
+ }
33
+
34
+ async function typeKeyword(el, keyword) {
35
+ const input = el.shadowRoot.getElementById('search');
36
+ input.value = keyword;
37
+ input.dispatchEvent(new Event('input', { bubbles: true }));
38
+ await settle(el);
39
+ }
40
+
41
+ function visibleOptionTexts(el) {
42
+ return Array.from(el.select.shadowRoot.querySelectorAll('[role="option"]')).map(o => o.textContent.trim());
43
+ }
44
+
45
+ async function selectOption(el, index) {
46
+ el.select.shadowRoot.querySelectorAll('[role="option"]')[index].click();
47
+ await el.select.updateComplete;
48
+ await el.updateComplete;
49
+ }
50
+
51
+ describe('Rendering', () => {
52
+ it('renders the search combobox and a label associated with it', async () => {
53
+ const el = await renderSelectSearch('label="Prefix"');
54
+
55
+ const input = el.shadowRoot.getElementById('search');
56
+ expect(input.getAttribute('role')).toBe('combobox');
57
+ const label = el.shadowRoot.querySelector('label');
58
+ expect(label.textContent).toBe('Prefix');
59
+ expect(label.getAttribute('for')).toBe('search');
60
+ });
61
+
62
+ it('throws when there is no select element in the light DOM', () => {
63
+ const el = document.createElement('dile-select-search');
64
+ expect(() => el.connectedCallback()).toThrow();
65
+ });
66
+ });
67
+
68
+ describe('Searching', () => {
69
+ it('filters the slotted options by text and opens the popup without waiting', async () => {
70
+ const el = await renderSelectSearch();
71
+
72
+ await typeKeyword(el, '3');
73
+
74
+ expect(visibleOptionTexts(el)).toEqual(['España (+34)', 'Portugal (+351)', 'France (+33)']);
75
+ expect(el.select.opened).toBe(true);
76
+ });
77
+
78
+ it('ignores case and accents', async () => {
79
+ const el = await renderSelectSearch();
80
+
81
+ await typeKeyword(el, 'ESPANA');
82
+
83
+ expect(visibleOptionTexts(el)).toEqual(['España (+34)']);
84
+ });
85
+
86
+ it('shows the empty message when nothing matches', async () => {
87
+ const el = await renderSelectSearch('emptyMessage="Nothing here"');
88
+
89
+ await typeKeyword(el, 'zz');
90
+
91
+ expect(el.shadowRoot.querySelector('.status').textContent.trim()).toBe('Nothing here');
92
+ expect(el.select.opened).toBe(false);
93
+ });
94
+
95
+ it('never shows a loading spinner (filtering is local)', async () => {
96
+ const el = await renderSelectSearch();
97
+
98
+ const input = el.shadowRoot.getElementById('search');
99
+ input.value = 'es';
100
+ input.dispatchEvent(new Event('input', { bubbles: true }));
101
+ await el.updateComplete;
102
+
103
+ expect(el.loading).toBe(false);
104
+ expect(el.shadowRoot.querySelector('dile-spinner-icon')).toBeNull();
105
+ });
106
+
107
+ it('can customize the matching by overriding filterOption()', async () => {
108
+ class PrefixSelect extends customElements.get('dile-select-search') {
109
+ filterOption(option, keyword) {
110
+ return option.value.toLowerCase().startsWith(keyword.toLowerCase());
111
+ }
112
+ }
113
+ customElements.define('test-prefix-select', PrefixSelect);
114
+ document.body.innerHTML = `<test-prefix-select>${OPTIONS}</test-prefix-select>`;
115
+ const el = document.body.querySelector('test-prefix-select');
116
+ await el.updateComplete;
117
+
118
+ await typeKeyword(el, 'p');
119
+
120
+ expect(visibleOptionTexts(el)).toEqual(['Portugal (+351)']);
121
+ });
122
+
123
+ it('picks up options added to the light DOM select after mount', async () => {
124
+ const el = await renderSelectSearch();
125
+ const option = document.createElement('option');
126
+ option.value = 'IT';
127
+ option.textContent = 'Italia (+39)';
128
+ el.querySelector('select').appendChild(option);
129
+ await settle(el);
130
+
131
+ await typeKeyword(el, 'ital');
132
+
133
+ expect(visibleOptionTexts(el)).toEqual(['Italia (+39)']);
134
+ });
135
+ });
136
+
137
+ describe('Search field icon', () => {
138
+ it('shows a decorative search icon, not a button, while the field is empty', async () => {
139
+ const el = await renderSelectSearch();
140
+
141
+ expect(el.shadowRoot.querySelector('.clear-button')).toBeNull();
142
+ expect(el.shadowRoot.querySelector('.search-wrapper dile-icon').getAttribute('aria-hidden')).toBe('true');
143
+ });
144
+
145
+ it('shows a real, labelled clear button once there is text', async () => {
146
+ const el = await renderSelectSearch('clearSearchLabel="Borrar búsqueda"');
147
+ await typeKeyword(el, 'esp');
148
+
149
+ const button = el.shadowRoot.querySelector('.clear-button');
150
+ expect(button.tagName).toBe('BUTTON');
151
+ expect(button.type).toBe('button');
152
+ expect(button.getAttribute('aria-label')).toBe('Borrar búsqueda');
153
+ });
154
+
155
+ it('the clear button empties the search and returns the focus to the field', async () => {
156
+ const el = await renderSelectSearch();
157
+ await typeKeyword(el, 'esp');
158
+
159
+ el.shadowRoot.querySelector('.clear-button').click();
160
+ await el.updateComplete;
161
+
162
+ expect(el.keyword).toBe('');
163
+ expect(el.shadowRoot.activeElement).toBe(el.shadowRoot.getElementById('search'));
164
+ expect(el.shadowRoot.querySelector('.clear-button')).toBeNull();
165
+ });
166
+ });
167
+
168
+ describe('showAllOnFocus', () => {
169
+ it('shows every option on focus when set', async () => {
170
+ const el = await renderSelectSearch('showAllOnFocus');
171
+
172
+ el.shadowRoot.getElementById('search').focus();
173
+ await settle(el);
174
+
175
+ expect(visibleOptionTexts(el).length).toBe(4);
176
+ expect(el.select.opened).toBe(true);
177
+ });
178
+
179
+ it('shows nothing on focus by default', async () => {
180
+ const el = await renderSelectSearch();
181
+
182
+ el.shadowRoot.getElementById('search').focus();
183
+ await settle(el);
184
+
185
+ expect(visibleOptionTexts(el).length).toBe(0);
186
+ expect(el.select.opened).toBe(false);
187
+ });
188
+ });
189
+
190
+ describe('Selecting', () => {
191
+ it('selecting an option sets value/selectedText and fires events', async () => {
192
+ const el = await renderSelectSearch('name="prefix"');
193
+ await typeKeyword(el, 'bra');
194
+
195
+ let changedDetail = null;
196
+ let selectedTextDetail = null;
197
+ el.addEventListener('element-changed', (e) => { changedDetail = e.detail; });
198
+ el.addEventListener('dile-select-search-selected-text-changed', (e) => { selectedTextDetail = e.detail; });
199
+
200
+ await selectOption(el, 0);
201
+
202
+ expect(el.value).toBe('BR');
203
+ expect(el.selectedText).toBe('Brasil (+55)');
204
+ expect(el.isSelected).toBe(true);
205
+ expect(changedDetail).toEqual({ name: 'prefix', value: 'BR' });
206
+ expect(selectedTextDetail.selectedText).toBe('Brasil (+55)');
207
+ expect(el.shadowRoot.getElementById('result')).toBeTruthy();
208
+ });
209
+
210
+ it('keyboard flow (ArrowDown + Enter) selects a result', async () => {
211
+ const el = await renderSelectSearch();
212
+ await typeKeyword(el, 'port');
213
+
214
+ const input = el.shadowRoot.getElementById('search');
215
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
216
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
217
+ await el.select.updateComplete;
218
+ await el.updateComplete;
219
+
220
+ expect(el.value).toBe('PT');
221
+ expect(el.isSelected).toBe(true);
222
+ });
223
+
224
+ it('clearing the selection returns to the search field', async () => {
225
+ const el = await renderSelectSearch();
226
+ await typeKeyword(el, 'bra');
227
+ await selectOption(el, 0);
228
+
229
+ el.shadowRoot.getElementById('result').dispatchEvent(new CustomEvent('dile-input-search-cleared', { bubbles: true }));
230
+ await el.updateComplete;
231
+
232
+ expect(el.isSelected).toBe(false);
233
+ expect(el.value).toBeUndefined();
234
+ expect(el.shadowRoot.getElementById('search')).toBeTruthy();
235
+ });
236
+
237
+ it('clearing with showAllOnFocus does not auto-select the first option of the remounted list', async () => {
238
+ const el = await renderSelectSearch('showAllOnFocus');
239
+ await typeKeyword(el, 'bra');
240
+ await selectOption(el, 0);
241
+
242
+ const changedValues = [];
243
+ el.addEventListener('element-changed', (e) => { changedValues.push(e.detail.value); });
244
+
245
+ el.shadowRoot.getElementById('result').dispatchEvent(new CustomEvent('dile-input-search-cleared', { bubbles: true }));
246
+ await settle(el);
247
+ await settle(el);
248
+
249
+ expect(el.value).toBeUndefined();
250
+ expect(el.isSelected).toBe(false);
251
+ expect(changedValues).toEqual([undefined]);
252
+ });
253
+ });
254
+
255
+ describe('Value API', () => {
256
+ it('resolves the selected text of an initial value from the slotted options', async () => {
257
+ const el = await renderSelectSearch('value="PT"');
258
+
259
+ expect(el.isSelected).toBe(true);
260
+ expect(el.selectedText).toBe('Portugal (+351)');
261
+ expect(el.shadowRoot.getElementById('result').value).toBe('Portugal (+351)');
262
+ });
263
+
264
+ it('uses the label as accessible name of the selected field', async () => {
265
+ const el = await renderSelectSearch('value="PT" label="Country"');
266
+ await el.updateComplete;
267
+ const result = el.shadowRoot.getElementById('result');
268
+ await result.updateComplete;
269
+ const input = result.shadowRoot.querySelector('input');
270
+
271
+ expect(input.getAttribute('aria-label')).toBe('Country');
272
+ });
273
+
274
+ it('set() selects a value and resolves its text synchronously', async () => {
275
+ const el = await renderSelectSearch();
276
+
277
+ el.set('FR');
278
+ await el.updateComplete;
279
+
280
+ expect(el.value).toBe('FR');
281
+ expect(el.selectedText).toBe('France (+33)');
282
+ expect(el.isSelected).toBe(true);
283
+ });
284
+
285
+ it('clear() resets value and selection', async () => {
286
+ const el = await renderSelectSearch('value="ES"');
287
+
288
+ el.clear();
289
+ await el.updateComplete;
290
+
291
+ expect(el.value).toBeUndefined();
292
+ expect(el.isSelected).toBe(false);
293
+ expect(el.shadowRoot.getElementById('search')).toBeTruthy();
294
+ });
295
+
296
+ it('is form-associated and sets the form value', async () => {
297
+ document.body.innerHTML = `<form><dile-select-search name="prefix" value="ES">${OPTIONS}</dile-select-search></form>`;
298
+ const el = document.body.querySelector('dile-select-search');
299
+ await el.updateComplete;
300
+
301
+ const formData = new FormData(document.body.querySelector('form'));
302
+ expect(formData.get('prefix')).toBe('ES');
303
+ });
304
+ });
305
+
306
+ describe('Icons', () => {
307
+ it('reads data-icon from options, falling back to the select data-icon', async () => {
308
+ const el = await renderSelectSearch('', `
309
+ <select slot="select" data-icon="lucide.globe">
310
+ <option value="ES" data-icon="material.star">España</option>
311
+ <option value="PT">Portugal</option>
312
+ </select>
313
+ `);
314
+
315
+ expect(el.sourceOptions.map(o => o.icon)).toEqual(['material.star', 'lucide.globe']);
316
+ });
317
+ });
318
+ });