@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.
- package/components/drop-file/drop-file.spec.js +31 -0
- package/components/drop-file/src/DileDropFile.js +12 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
119
|
-
:
|
|
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 ||
|
|
147
|
+
aria-label="${this.accessibleLabel || this.name || this.searchButtonLabel}"
|
|
136
148
|
?aria-invalid="${this.errored}"
|
|
137
|
-
aria-describedby="${this.
|
|
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
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
51
|
+
el.shadowRoot.querySelector('a').click();
|
|
52
52
|
await el.updateComplete;
|
|
53
53
|
|
|
54
54
|
expect(el.value).toBe('desc'); // toggled
|
|
Binary file
|
|
@@ -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,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
|
+
});
|