@dile/ui 3.4.6 → 3.5.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.
Files changed (19) hide show
  1. package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-Selecting-a-result-clearing-the-selection-returns-to-the-search-field-1.png +0 -0
  2. package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-Selecting-a-result-selecting-an-option-sets-value-selectedText--closes-the-popup-and-fires-events-1.png +0 -0
  3. package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-Selecting-a-result-shows-the-read-only-field-with-the-clear-icon-after-selecting-1.png +0 -0
  4. package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-static-mode-loads-all-data-on-init-and-renders-it-inline-without-requiring-a-search-1.png +0 -0
  5. package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-static-mode-shows-a-visible--clickable-trigger-instead-of-hiding-it--there-is-no-separate-search-field-to-compose-under--1.png +0 -0
  6. package/components/select/__screenshots__/select-overlay.spec.js/dile-select-overlay-Positioning-keeps-the-same-moveTop-tightened-gap-when-flipping-above-the-trigger-as-when-sitting-below-it-1.png +0 -0
  7. package/components/select/__screenshots__/select-overlay.spec.js/dile-select-overlay-Rendering-throws-when-no-select-element-is-slotted-1.png +0 -0
  8. package/components/select/__screenshots__/select-overlay.spec.js/dile-select-overlay-hideTrigger-handleExternalKeydown---drives-navigation-without-a-visible-trigger-1.png +0 -0
  9. package/components/select/__screenshots__/zzdebug2.spec.js/debug-static-debug-1.png +0 -0
  10. package/components/select/__screenshots__/zzdebug3.spec.js/debug-rapid-close-reopen-reproduces-blur-then-quick-refocus-1.png +0 -0
  11. package/components/select/index.js +3 -1
  12. package/components/select/select-ajax-overlay.js +6 -0
  13. package/components/select/select-ajax-overlay.spec.js +389 -0
  14. package/components/select/select-overlay.js +5 -0
  15. package/components/select/select-overlay.spec.js +507 -0
  16. package/components/select/src/DileSelectAjaxOverlay.js +521 -0
  17. package/components/select/src/DileSelectOverlay.js +511 -0
  18. package/mixins/overlay/src/DileOverlay.js +9 -6
  19. package/package.json +2 -2
@@ -0,0 +1,507 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import './select-overlay.js';
3
+
4
+ describe('dile-select-overlay', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ async function renderSelectOverlay(html) {
10
+ document.body.innerHTML = html;
11
+ const el = document.body.querySelector('dile-select-overlay');
12
+ await el.updateComplete;
13
+ return el;
14
+ }
15
+
16
+ function wait(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+
20
+ async function open(el) {
21
+ el.shadowRoot.getElementById('trigger').click();
22
+ await wait(100);
23
+ await el.updateComplete;
24
+ }
25
+
26
+ describe('Rendering', () => {
27
+ it('renders a trigger button and a listbox overlay', async () => {
28
+ const el = await renderSelectOverlay(`
29
+ <dile-select-overlay>
30
+ <select slot="select">
31
+ <option value="1">Option 1</option>
32
+ <option value="2">Option 2</option>
33
+ </select>
34
+ </dile-select-overlay>
35
+ `);
36
+
37
+ const trigger = el.shadowRoot.getElementById('trigger');
38
+ const overlay = el.shadowRoot.getElementById('overlay');
39
+ expect(trigger).toBeTruthy();
40
+ expect(trigger.getAttribute('role')).toBe('combobox');
41
+ expect(overlay).toBeTruthy();
42
+ expect(overlay.getAttribute('role')).toBe('listbox');
43
+ });
44
+
45
+ it('shows the selected option text on the trigger', async () => {
46
+ const el = await renderSelectOverlay(`
47
+ <dile-select-overlay value="2">
48
+ <select slot="select">
49
+ <option value="1">Option 1</option>
50
+ <option value="2">Option 2</option>
51
+ </select>
52
+ </dile-select-overlay>
53
+ `);
54
+
55
+ const trigger = el.shadowRoot.getElementById('trigger');
56
+ expect(trigger.textContent.trim()).toBe('Option 2');
57
+ });
58
+
59
+ it('shows the placeholder text on the trigger when there is no matching option (e.g. mounted before options load)', async () => {
60
+ const el = await renderSelectOverlay(`
61
+ <dile-select-overlay placeholder="Choose one...">
62
+ <select slot="select"></select>
63
+ </dile-select-overlay>
64
+ `);
65
+
66
+ const triggerText = el.shadowRoot.querySelector('.trigger-text');
67
+ expect(triggerText.textContent.trim()).toBe('Choose one...');
68
+ expect(triggerText.classList.contains('placeholder')).toBe(true);
69
+ });
70
+
71
+ it('renders a label associated with the trigger', async () => {
72
+ const el = await renderSelectOverlay(`
73
+ <dile-select-overlay label="Choose option">
74
+ <select slot="select">
75
+ <option value="1">Option 1</option>
76
+ </select>
77
+ </dile-select-overlay>
78
+ `);
79
+
80
+ const label = el.shadowRoot.querySelector('label');
81
+ expect(label).toBeTruthy();
82
+ expect(label.textContent).toBe('Choose option');
83
+ expect(label.getAttribute('for')).toBe('trigger');
84
+ });
85
+
86
+ it('renders one option item per native option', async () => {
87
+ const el = await renderSelectOverlay(`
88
+ <dile-select-overlay>
89
+ <select slot="select">
90
+ <option value="1">Option 1</option>
91
+ <option value="2">Option 2</option>
92
+ <option value="3">Option 3</option>
93
+ </select>
94
+ </dile-select-overlay>
95
+ `);
96
+
97
+ const items = el.shadowRoot.querySelectorAll('[role="option"]');
98
+ expect(items.length).toBe(3);
99
+ });
100
+
101
+ it('picks up options added to the native select after connection', async () => {
102
+ const el = await renderSelectOverlay(`
103
+ <dile-select-overlay>
104
+ <select slot="select">
105
+ <option value="1">Option 1</option>
106
+ </select>
107
+ </dile-select-overlay>
108
+ `);
109
+
110
+ const option = document.createElement('option');
111
+ option.value = '2';
112
+ option.textContent = 'Option 2';
113
+ el.querySelector('select').appendChild(option);
114
+ await wait(0);
115
+ await el.updateComplete;
116
+
117
+ const items = el.shadowRoot.querySelectorAll('[role="option"]');
118
+ expect(items.length).toBe(2);
119
+ });
120
+
121
+ it('recomputes position when the option list changes while open (e.g. a search narrowing the results)', async () => {
122
+ // Regression test: DileOverlay only repositions on open/scroll/resize. Without also
123
+ // repositioning when the option count changes, a popup that flipped above the trigger
124
+ // for a tall list stayed anchored at that old top position once it shrank, leaving it
125
+ // floating detached from the trigger instead of hugging it.
126
+ const el = await renderSelectOverlay(`
127
+ <dile-select-overlay>
128
+ <select id="manyOptions" slot="select">
129
+ <option value="1">Option 1</option>
130
+ </select>
131
+ </dile-select-overlay>
132
+ `);
133
+
134
+ const select = el.querySelector('select');
135
+ for (let i = 2; i <= 10; i++) {
136
+ const option = document.createElement('option');
137
+ option.value = String(i);
138
+ option.textContent = `Option ${i}`;
139
+ select.appendChild(option);
140
+ }
141
+ await open(el);
142
+
143
+ const updatePositionSpy = vi.spyOn(el, 'updatePosition');
144
+
145
+ // Narrow down to a single option, same as a search field going from many results to one.
146
+ select.innerHTML = '<option value="1">Option 1</option>';
147
+ await wait(0);
148
+ await el.updateComplete;
149
+
150
+ expect(updatePositionSpy).toHaveBeenCalled();
151
+ });
152
+ });
153
+
154
+ describe('Opening and closing', () => {
155
+ it('opens the listbox when the trigger is clicked', async () => {
156
+ const el = await renderSelectOverlay(`
157
+ <dile-select-overlay>
158
+ <select slot="select">
159
+ <option value="1">Option 1</option>
160
+ <option value="2">Option 2</option>
161
+ </select>
162
+ </dile-select-overlay>
163
+ `);
164
+
165
+ await open(el);
166
+
167
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(true);
168
+ expect(el.shadowRoot.getElementById('trigger').getAttribute('aria-expanded')).toBe('true');
169
+ });
170
+
171
+ it('closes the listbox on a second trigger click', async () => {
172
+ const el = await renderSelectOverlay(`
173
+ <dile-select-overlay>
174
+ <select slot="select">
175
+ <option value="1">Option 1</option>
176
+ </select>
177
+ </dile-select-overlay>
178
+ `);
179
+
180
+ await open(el);
181
+ el.shadowRoot.getElementById('trigger').click();
182
+ await wait(600);
183
+
184
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(false);
185
+ });
186
+
187
+ it('closes without changing the value on Escape', async () => {
188
+ const el = await renderSelectOverlay(`
189
+ <dile-select-overlay value="1">
190
+ <select slot="select">
191
+ <option value="1">Option 1</option>
192
+ <option value="2">Option 2</option>
193
+ </select>
194
+ </dile-select-overlay>
195
+ `);
196
+
197
+ await open(el);
198
+ el.shadowRoot.getElementById('trigger').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
199
+ await wait(600);
200
+
201
+ expect(el.value).toBe('1');
202
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(false);
203
+ });
204
+
205
+ it('stays open when reopened shortly after closing (e.g. blur then a quick refocus click)', async () => {
206
+ // Regression test: close() schedules a 500ms timer to set display:none after the fade-out
207
+ // transition. If that timer isn't actually cancelled by a reopen happening in the meantime,
208
+ // it still fires later and force-hides the popup even though it's now open again — a brief
209
+ // flicker followed by the popup disappearing.
210
+ const el = await renderSelectOverlay(`
211
+ <dile-select-overlay>
212
+ <select slot="select">
213
+ <option value="1">Option 1</option>
214
+ <option value="2">Option 2</option>
215
+ </select>
216
+ </dile-select-overlay>
217
+ `);
218
+
219
+ await open(el);
220
+ el.close();
221
+ await wait(50);
222
+ el.open();
223
+ await wait(100);
224
+
225
+ // Wait past the original close()'s 500ms hide-delay window.
226
+ await wait(500);
227
+
228
+ const overlay = el.shadowRoot.getElementById('overlay');
229
+ expect(overlay.classList.contains('opened')).toBe(true);
230
+ expect(overlay.style.display).toBe('block');
231
+ });
232
+ });
233
+
234
+ describe('Positioning', () => {
235
+ it('keeps the same moveTop-tightened gap when flipping above the trigger as when sitting below it', async () => {
236
+ // Regression test: moveTop used to only tighten the gap for the normal (below) position.
237
+ // The flip-to-above fallback used a fixed 10px margin unrelated to moveTop, so a popup
238
+ // that fit comfortably below its trigger would visibly detach once it had to flip above.
239
+ // A tall spacer pushes the trigger well down the page, so a forced small viewport height
240
+ // reliably makes it not fit below without also failing to fit above (which would hit an
241
+ // unrelated "doesn't fit either way" fallback instead of the plain flip this test targets).
242
+ const el = await renderSelectOverlay(`
243
+ <div style="height: 300px;"></div>
244
+ <dile-select-overlay>
245
+ <select slot="select">
246
+ <option value="1">Option 1</option>
247
+ <option value="2">Option 2</option>
248
+ <option value="3">Option 3</option>
249
+ </select>
250
+ </dile-select-overlay>
251
+ `);
252
+
253
+ const triggerTop = el.shadowRoot.getElementById('trigger').getBoundingClientRect().top;
254
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: Math.round(triggerTop + 50) });
255
+ try {
256
+ el.open();
257
+ await wait(100);
258
+
259
+ const trigger = el.shadowRoot.getElementById('trigger');
260
+ const overlay = el.shadowRoot.getElementById('overlay');
261
+ const gap = trigger.getBoundingClientRect().top - overlay.getBoundingClientRect().bottom;
262
+
263
+ // Default DileOverlay margin (10px) plus this component's moveTop (-8px) should land
264
+ // close to 2px — well under the untightened 10px default, and not overlapping (negative).
265
+ expect(gap).toBeGreaterThanOrEqual(0);
266
+ expect(gap).toBeLessThan(9);
267
+ } finally {
268
+ delete document.documentElement.clientHeight;
269
+ }
270
+ });
271
+ });
272
+
273
+ describe('Keyboard navigation', () => {
274
+ it('ArrowDown opens the listbox and highlights the first option', async () => {
275
+ const el = await renderSelectOverlay(`
276
+ <dile-select-overlay>
277
+ <select slot="select">
278
+ <option value="1">Option 1</option>
279
+ <option value="2">Option 2</option>
280
+ </select>
281
+ </dile-select-overlay>
282
+ `);
283
+
284
+ const trigger = el.shadowRoot.getElementById('trigger');
285
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
286
+ await wait(100);
287
+ await el.updateComplete;
288
+
289
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(true);
290
+ expect(el._activeIndex).toBeGreaterThanOrEqual(0);
291
+ expect(el.highlightedText).toBeTruthy();
292
+ });
293
+
294
+ it('Enter selects the highlighted option and fires element-changed', async () => {
295
+ const el = await renderSelectOverlay(`
296
+ <dile-select-overlay>
297
+ <select slot="select">
298
+ <option value="1">Option 1</option>
299
+ <option value="2">Option 2</option>
300
+ </select>
301
+ </dile-select-overlay>
302
+ `);
303
+
304
+ let eventDetail = null;
305
+ el.addEventListener('element-changed', (e) => { eventDetail = e.detail; });
306
+
307
+ const trigger = el.shadowRoot.getElementById('trigger');
308
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
309
+ await wait(100);
310
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
311
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
312
+ await el.updateComplete;
313
+
314
+ expect(el.value).toBe('2');
315
+ expect(eventDetail.value).toBe('2');
316
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(false);
317
+ });
318
+
319
+ it('typeahead selects the matching option immediately while closed', async () => {
320
+ const el = await renderSelectOverlay(`
321
+ <dile-select-overlay>
322
+ <select slot="select">
323
+ <option value="1">Apple</option>
324
+ <option value="2">Banana</option>
325
+ </select>
326
+ </dile-select-overlay>
327
+ `);
328
+
329
+ const trigger = el.shadowRoot.getElementById('trigger');
330
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', bubbles: true }));
331
+ await el.updateComplete;
332
+
333
+ expect(el.value).toBe('2');
334
+ });
335
+ });
336
+
337
+ describe('Mouse selection', () => {
338
+ it('clicking an option selects it and closes the listbox', async () => {
339
+ const el = await renderSelectOverlay(`
340
+ <dile-select-overlay>
341
+ <select slot="select">
342
+ <option value="1">Option 1</option>
343
+ <option value="2">Option 2</option>
344
+ </select>
345
+ </dile-select-overlay>
346
+ `);
347
+
348
+ await open(el);
349
+ const options = el.shadowRoot.querySelectorAll('[role="option"]');
350
+ options[1].click();
351
+ await el.updateComplete;
352
+
353
+ expect(el.value).toBe('2');
354
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(false);
355
+ });
356
+ });
357
+
358
+ describe('hideTrigger', () => {
359
+ it('keeps the trigger out of the tab order and still opens via open()', async () => {
360
+ const el = await renderSelectOverlay(`
361
+ <dile-select-overlay hideTrigger>
362
+ <select slot="select">
363
+ <option value="1">Option 1</option>
364
+ <option value="2">Option 2</option>
365
+ </select>
366
+ </dile-select-overlay>
367
+ `);
368
+
369
+ const trigger = el.shadowRoot.getElementById('trigger');
370
+ expect(trigger.getAttribute('tabindex')).toBe('-1');
371
+
372
+ el.open();
373
+ await wait(100);
374
+
375
+ expect(el.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(true);
376
+ });
377
+
378
+ it('handleExternalKeydown() drives navigation without a visible trigger', async () => {
379
+ const el = await renderSelectOverlay(`
380
+ <dile-select-overlay hideTrigger>
381
+ <select slot="select">
382
+ <option value="1">Option 1</option>
383
+ <option value="2">Option 2</option>
384
+ </select>
385
+ </dile-select-overlay>
386
+ `);
387
+
388
+ // Starting value defaults to the first option ("1"); ArrowDown from closed
389
+ // opens the popup and moves the highlight to the next option ("2"), matching
390
+ // the WAI-ARIA APG "select-only combobox" Down Arrow behavior.
391
+ el.handleExternalKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
392
+ await wait(100);
393
+ el.handleExternalKeydown(new KeyboardEvent('keydown', { key: 'Enter' }));
394
+ await el.updateComplete;
395
+
396
+ expect(el.value).toBe('2');
397
+ });
398
+
399
+ it('setTriggerElement() points positioning at an external element instead of the internal trigger', async () => {
400
+ // Regression test: a hideTrigger instance's own trigger is a zero-height point, so a
401
+ // parent composing this component (e.g. dile-select-ajax-overlay) needs to be able to
402
+ // anchor positioning to its own real, visible control instead — otherwise a flip-to-top
403
+ // popup doesn't know to leave room for that control's actual height and covers it.
404
+ const el = await renderSelectOverlay(`
405
+ <dile-select-overlay hideTrigger>
406
+ <select slot="select">
407
+ <option value="1">Option 1</option>
408
+ </select>
409
+ </dile-select-overlay>
410
+ `);
411
+
412
+ const external = document.createElement('div');
413
+ document.body.appendChild(external);
414
+
415
+ el.setTriggerElement(external);
416
+ expect(el.trigger).toBe(external);
417
+
418
+ el.setTriggerElement();
419
+ expect(el.trigger).toBe(el.shadowRoot.getElementById('trigger'));
420
+ });
421
+ });
422
+
423
+ describe('Methods', () => {
424
+ it('clear() sets value to undefined', async () => {
425
+ const el = await renderSelectOverlay(`
426
+ <dile-select-overlay value="1">
427
+ <select slot="select">
428
+ <option value="1">Option 1</option>
429
+ <option value="2">Option 2</option>
430
+ </select>
431
+ </dile-select-overlay>
432
+ `);
433
+
434
+ el.clear();
435
+ await el.updateComplete;
436
+
437
+ expect(el.value).toBeUndefined();
438
+ });
439
+
440
+ it('getOptionByValue() returns the matching native option', async () => {
441
+ const el = await renderSelectOverlay(`
442
+ <dile-select-overlay>
443
+ <select slot="select">
444
+ <option value="1">Option 1</option>
445
+ <option value="2">Option 2</option>
446
+ </select>
447
+ </dile-select-overlay>
448
+ `);
449
+
450
+ const option = el.getOptionByValue('2');
451
+ expect(option).toBeTruthy();
452
+ expect(option.textContent).toBe('Option 2');
453
+ });
454
+
455
+ it('quietChange() updates value without emitting element-changed', async () => {
456
+ const el = await renderSelectOverlay(`
457
+ <dile-select-overlay value="1">
458
+ <select slot="select">
459
+ <option value="1">Option 1</option>
460
+ <option value="2">Option 2</option>
461
+ </select>
462
+ </dile-select-overlay>
463
+ `);
464
+
465
+ let eventCount = 0;
466
+ el.addEventListener('element-changed', () => { eventCount++; });
467
+
468
+ el.quietChange('2');
469
+ await el.updateComplete;
470
+
471
+ expect(el.value).toBe('2');
472
+ expect(eventCount).toBe(0);
473
+ });
474
+ });
475
+
476
+ describe('Form Association', () => {
477
+ it('is form-associated', async () => {
478
+ const el = await renderSelectOverlay(`
479
+ <dile-select-overlay>
480
+ <select slot="select">
481
+ <option value="1">Option 1</option>
482
+ </select>
483
+ </dile-select-overlay>
484
+ `);
485
+
486
+ expect(el.internals).toBeTruthy();
487
+ });
488
+
489
+ it('participates in FormData when name is set', async () => {
490
+ document.body.innerHTML = `
491
+ <form id="testForm">
492
+ <dile-select-overlay name="fruit" value="2">
493
+ <select slot="select">
494
+ <option value="1">Option 1</option>
495
+ <option value="2">Option 2</option>
496
+ </select>
497
+ </dile-select-overlay>
498
+ </form>
499
+ `;
500
+ const el = document.body.querySelector('dile-select-overlay');
501
+ await el.updateComplete;
502
+
503
+ const data = new FormData(document.getElementById('testForm'));
504
+ expect(data.get('fruit')).toBe('2');
505
+ });
506
+ });
507
+ });