@dile/ui 3.13.1 → 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.
- package/components/drop-file/drop-file.spec.js +79 -0
- package/components/drop-file/src/DileDropFile.js +18 -1
- package/components/input/input-search.spec.js +42 -0
- package/components/input/src/DileInputSearch.js +32 -13
- package/components/order-switch/order-switch.spec.js +4 -4
- package/components/select/__screenshots__/select-search.spec.js/dile-select-search-Value-API-uses-the-label-as-accessible-name-of-the-selected-field-1.png +0 -0
- package/components/select/index.js +3 -1
- package/components/select/select-overlay.spec.js +16 -0
- package/components/select/select-search.js +6 -0
- package/components/select/select-search.spec.js +318 -0
- package/components/select/src/DileSelectAjaxOverlay.js +18 -449
- package/components/select/src/DileSelectOverlay.js +3 -0
- package/components/select/src/DileSelectSearch.js +129 -0
- package/components/select/src/DileSelectSearchableBase.js +544 -0
- package/package.json +2 -2
|
@@ -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
|
+
});
|