@dile/ui 3.4.6 → 3.5.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.
Files changed (20) hide show
  1. package/components/select/__screenshots__/select-ajax-overlay.spec.js/dile-select-ajax-overlay-Selecting-a-result-clear---does-not-re-select-the-same-item--regression--infinite-add-loop-under-addOnSelect--1.png +0 -0
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. package/components/select/__screenshots__/select-overlay.spec.js/dile-select-overlay-Rendering-throws-when-no-select-element-is-slotted-1.png +0 -0
  9. package/components/select/__screenshots__/select-overlay.spec.js/dile-select-overlay-hideTrigger-handleExternalKeydown---drives-navigation-without-a-visible-trigger-1.png +0 -0
  10. package/components/select/__screenshots__/zzdebug2.spec.js/debug-static-debug-1.png +0 -0
  11. package/components/select/__screenshots__/zzdebug3.spec.js/debug-rapid-close-reopen-reproduces-blur-then-quick-refocus-1.png +0 -0
  12. package/components/select/index.js +3 -1
  13. package/components/select/select-ajax-overlay.js +6 -0
  14. package/components/select/select-ajax-overlay.spec.js +422 -0
  15. package/components/select/select-overlay.js +5 -0
  16. package/components/select/select-overlay.spec.js +507 -0
  17. package/components/select/src/DileSelectAjaxOverlay.js +531 -0
  18. package/components/select/src/DileSelectOverlay.js +511 -0
  19. package/mixins/overlay/src/DileOverlay.js +9 -6
  20. package/package.json +2 -2
@@ -0,0 +1,531 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
3
+ import '../../spinner/spinner-horizontal.js';
4
+ import '../../spinner/spinner-icon.js';
5
+ import '../../icon/icon.js';
6
+ import '../select-overlay.js';
7
+ import '../../input/input-search.js';
8
+ import { searchIcon, clearIcon } from '@dile/icons/index.js';
9
+ import { DileEmmitChange } from '../../../mixins/form/index.js';
10
+ import { labelStyles } from '../../input/src/label-styles.js';
11
+ import { messageStyles } from '../../input/src/message-styles.js';
12
+
13
+ const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter', 'Escape', 'Tab'];
14
+
15
+ export class DileSelectAjaxOverlay extends DileEmmitChange(LitElement) {
16
+
17
+ static get styles() {
18
+ return [
19
+ labelStyles,
20
+ messageStyles,
21
+ css`
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+ :host {
26
+ display: block;
27
+ position: relative;
28
+ margin-bottom: 10px;
29
+ }
30
+ #result {
31
+ margin-bottom: 0.5rem;
32
+ }
33
+ .search-wrapper {
34
+ position: relative;
35
+ display: flex;
36
+ align-items: center;
37
+ width: var(--dile-input-width, 100%);
38
+ padding: var(--dile-input-padding, 5px);
39
+ border-radius: var(--dile-input-border-radius, 5px);
40
+ border: var(--dile-input-border-width, 1px) solid var(--dile-input-border-color, #888);
41
+ background-color: var(--dile-input-background-color, #fff);
42
+ }
43
+ .search-wrapper:focus-within {
44
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
45
+ }
46
+ .search-wrapper.errored {
47
+ border-color: var(--dile-input-error-border-color, #c00);
48
+ }
49
+ .search-wrapper.disabled {
50
+ opacity: 0.5;
51
+ }
52
+ input {
53
+ flex-grow: 1;
54
+ min-width: 0;
55
+ border: none;
56
+ outline: none;
57
+ color: var(--dile-input-color, #303030);
58
+ font-size: var(--dile-input-font-size, 1em);
59
+ line-height: var(--dile-input-line-height, 1.5em);
60
+ background-color: transparent;
61
+ font-family: inherit;
62
+ }
63
+ input::placeholder {
64
+ color: var(--dile-input-placeholder-color, #ccc);
65
+ }
66
+ dile-icon {
67
+ margin-left: 1px;
68
+ display: flex;
69
+ cursor: pointer;
70
+ }
71
+ dile-spinner-icon {
72
+ margin-left: 1px;
73
+ --dile-icon-size: 18px;
74
+ }
75
+ .status {
76
+ margin-top: 0.5rem;
77
+ position: absolute;
78
+ z-index: var(--dile-select-overlay-z-index, 999);
79
+ width: 100%;
80
+ box-sizing: border-box;
81
+ min-height: 2.5rem;
82
+ border-radius: var(--dile-select-overlay-border-radius, 5px);
83
+ padding: var(--dile-select-overlay-padding, 7px 10px);
84
+ background-color: var(--dile-select-overlay-background-color, #fff);
85
+ color: var(--dile-select-overlay-color, #303030);
86
+ box-shadow: var(--dile-select-overlay-box-shadow, 0 4px 10px rgba(0, 0, 0, 0.15));
87
+ display: flex;
88
+ align-items: center;
89
+ }
90
+ .status p {
91
+ margin: 0;
92
+ }
93
+ dile-select-overlay {
94
+ margin-bottom: 0;
95
+ /* Match the search field's own sizing (dile-input-search-like padding/font-size)
96
+ instead of dile-select-overlay's native-select-like defaults, so the visible
97
+ trigger shown in static mode looks consistent with the search field. */
98
+ --dile-input-padding: 5px;
99
+ --dile-select-font-size: 1em;
100
+ }
101
+ .visually-hidden {
102
+ position: absolute;
103
+ width: 1px;
104
+ height: 1px;
105
+ padding: 0;
106
+ margin: -1px;
107
+ overflow: hidden;
108
+ clip: rect(0, 0, 0, 0);
109
+ white-space: nowrap;
110
+ border: 0;
111
+ }
112
+ `];
113
+ }
114
+
115
+ static get formAssociated() {
116
+ return true;
117
+ }
118
+
119
+ static get properties() {
120
+ return {
121
+ endpoint: { type: String },
122
+ label: { type: String },
123
+ value: { type: String },
124
+ name: { type: String, reflect: true },
125
+ disabled: { type: Boolean },
126
+ errored: { type: Boolean },
127
+ data: { type: Array },
128
+ placeholder: { type: String },
129
+ emptyMessage: { type: String },
130
+ selectedText: { type: String },
131
+ ajaxErrorMessage: { type: String },
132
+ ajaxError: { type: Boolean },
133
+ queryStringVariable: { type: String },
134
+ /** Use resultDataProperty to declare witch property in the JSON object returned by the endpoint has the data to assign to the select options. Left it blank if the result JSON has directly the array with the data */
135
+ resultDataProperty: { type: String },
136
+ displayProperty: { type: String },
137
+ idProperty: { type: String },
138
+ delay: { type: Number },
139
+ keyword: {
140
+ type: String,
141
+ state: true,
142
+ },
143
+ loading: {
144
+ type: Boolean,
145
+ state: true,
146
+ },
147
+ isSelected: {
148
+ type: Boolean,
149
+ state: true,
150
+ },
151
+ /* When static is true there is no posibility to search, then all the options are displayed on the select on initialization */
152
+ static: {
153
+ type: Boolean,
154
+ },
155
+ /** Message Displayed */
156
+ message: { type: String },
157
+ /** Hide errors on input */
158
+ hideErrorOnInput: { type: Boolean },
159
+ /** Additional query string data */
160
+ additionalQueryString: {type: Object },
161
+ /** Text announced in a live region as the user navigates results with the keyboard */
162
+ _liveAnnouncement: { state: true },
163
+ };
164
+ }
165
+
166
+ updated(changedProperties) {
167
+ if(changedProperties.has("value")) {
168
+ this.emmitChange();
169
+ this.dispatchSelectedTextChanged();
170
+ this.internals.setFormValue(this.value);
171
+ }
172
+ // In search mode the nested dile-select-overlay's own trigger is hidden and collapsed to
173
+ // zero height, sitting right after this component's real, visible search field. Point its
174
+ // positioning at that real field instead, so a flip-to-top popup correctly leaves room for
175
+ // the field's actual height rather than covering it. Static mode keeps its own visible
176
+ // trigger, which is already the right thing to position against.
177
+ // Re-checked on every update (not just firstUpdated) because clearing a selection swaps
178
+ // back to a *new* dile-select-overlay instance (isSelected toggles the whole template
179
+ // branch), which needs the same wiring the original instance got.
180
+ if (!this.static && this.select && this._wiredTriggerFor !== this.select) {
181
+ const nestedSelect = this.select;
182
+ this._wiredTriggerFor = nestedSelect;
183
+ // The nested element's own firstUpdated() (which sets its default internal trigger
184
+ // reference) runs on its own update cycle, not synchronously with this one — wait for it
185
+ // to resolve first, otherwise it overwrites the external trigger we just set here.
186
+ nestedSelect.updateComplete.then(() => {
187
+ nestedSelect.setTriggerElement(this.shadowRoot.querySelector('.search-wrapper'));
188
+ });
189
+ }
190
+ }
191
+
192
+ constructor() {
193
+ super();
194
+ this.placeholder = "Search to choose...";
195
+ this.emptyMessage = "No results found";
196
+ this.ajaxErrorMessage = "Error loading data";
197
+ this.data = [];
198
+ this.keyword = '';
199
+ this.loading = false;
200
+ this.errored = false;
201
+ this.ajaxError = false;
202
+ this.isSelected = false;
203
+ this.static = false;
204
+ this.queryStringVariable = 'q';
205
+ this.delay = 300;
206
+ this.idProperty = 'id';
207
+ this._liveAnnouncement = '';
208
+ this._debounceTimeout = null;
209
+ this.internals = this.attachInternals();
210
+ this.selectId = `dile-select-ajax-overlay-${Math.random().toString(36).substr(2, 9)}`;
211
+ }
212
+
213
+ firstUpdated() {
214
+ if(this.value) {
215
+ this.isSelected = true;
216
+ this.searchValueInitial();
217
+ }
218
+ if(this.static) {
219
+ this.loadData();
220
+ }
221
+ }
222
+
223
+ disconnectedCallback() {
224
+ super.disconnectedCallback();
225
+ if (this._debounceTimeout) {
226
+ clearTimeout(this._debounceTimeout);
227
+ }
228
+ }
229
+
230
+ searchValueInitial() {
231
+ fetch(`${this.endpoint}/${this.value}`)
232
+ .then(response => response.json())
233
+ .then(json => this.registerText(json))
234
+ .catch(error => this.registerError(error));
235
+ }
236
+
237
+ registerText(json) {
238
+ this.selectedText = json[this.resultDataProperty][this.displayProperty];
239
+ this.loading = false;
240
+ }
241
+
242
+ get search() {
243
+ return this.shadowRoot.getElementById('search');
244
+ }
245
+
246
+ get select() {
247
+ return this.shadowRoot.getElementById('elselect');
248
+ }
249
+
250
+ get opened() {
251
+ return this.select ? this.select.opened : false;
252
+ }
253
+
254
+ render() {
255
+ return html`
256
+ <div>
257
+ ${this.label
258
+ ? html`<label for="${this.isSelected ? 'result' : 'search'}">${this.label}</label>`
259
+ : ""}
260
+ ${this.isSelected
261
+ ? this.selectedTemplate
262
+ : this.static ? this.dataTemplate : this.searchTemplate
263
+ }
264
+ </div>
265
+ ${this.messageTemplate}
266
+ `;
267
+ }
268
+
269
+ get selectedTemplate() {
270
+ return html`
271
+ <dile-input-search
272
+ id="result"
273
+ ?errored=${this.errored}
274
+ ?disabled=${this.disabled}
275
+ value="${this.selectedText}"
276
+ readOnly
277
+ @dile-input-search-cleared=${this.onClearSelected}
278
+ @dile-input-search=${this.hideErrorOnInteraction}
279
+ ></dile-input-search>
280
+ `;
281
+ }
282
+
283
+ get searchTemplate() {
284
+ return html`
285
+ <div
286
+ class="search-wrapper ${this.errored ? 'errored' : ''} ${this.disabled ? 'disabled' : ''}"
287
+ @click=${(e) => e.stopPropagation()}
288
+ >
289
+ <input
290
+ type="text"
291
+ id="search"
292
+ role="combobox"
293
+ aria-haspopup="listbox"
294
+ aria-expanded="${this.opened}"
295
+ aria-invalid="${this.errored}"
296
+ aria-describedby="${ifDefined(this.message ? `${this.selectId}-message` : undefined)}"
297
+ placeholder="${this.placeholder}"
298
+ .value="${this.keyword}"
299
+ ?disabled=${this.disabled}
300
+ autocomplete="off"
301
+ @input=${this.onSearchInput}
302
+ @keydown=${this.onSearchKeydown}
303
+ @focus=${this.onSearchFocus}
304
+ @focusout=${this.onSearchFocusOut}
305
+ >
306
+ ${this.loading
307
+ ? html`<dile-spinner-icon active aria-hidden="true"></dile-spinner-icon>`
308
+ : html`
309
+ <dile-icon
310
+ @click=${this.onIconClick}
311
+ .icon="${this.keyword.length ? clearIcon : searchIcon}"
312
+ ></dile-icon>
313
+ `
314
+ }
315
+ </div>
316
+ <div class="visually-hidden" role="status" aria-live="polite">${this._liveAnnouncement}</div>
317
+ ${this.keyword.length > 0 && this.data.length === 0 ? this.statusTemplate : ''}
318
+ ${this.dataTemplate}
319
+ `;
320
+ }
321
+
322
+ get statusTemplate() {
323
+ return html`
324
+ <div class="status">
325
+ ${this.loading
326
+ ? html`<dile-spinner-horizontal active></dile-spinner-horizontal>`
327
+ : this.ajaxError
328
+ ? html`<p>${this.ajaxErrorMessage}</p>`
329
+ : html`<p>${this.emptyMessage}</p>`
330
+ }
331
+ </div>
332
+ `;
333
+ }
334
+
335
+ get dataTemplate() {
336
+ return html`
337
+ <dile-select-overlay
338
+ id="elselect"
339
+ ?hideTrigger=${!this.static}
340
+ name="generated-select-field"
341
+ placeholder="${this.placeholder}"
342
+ ?disabled=${this.disabled}
343
+ @element-changed=${this.doSelected}
344
+ >
345
+ <select slot="select">
346
+ ${this.data.map(item => html`
347
+ <option value="${item[this.idProperty]}">${item[this.displayProperty]}</option>
348
+ `)}
349
+ </select>
350
+ </dile-select-overlay>
351
+ `;
352
+ }
353
+
354
+ get messageTemplate() {
355
+ return html`
356
+ ${this.message
357
+ ? html`<div class="message ${this.errored ? 'errored-msg' : ''}" id="${this.selectId}-message"><span>${this.message}</span></div>`
358
+ : ''
359
+ }
360
+ `
361
+ }
362
+
363
+ onSearchInput(e) {
364
+ this.keyword = e.target.value;
365
+ this.hideErrorOnInteraction();
366
+ if (this._debounceTimeout) {
367
+ clearTimeout(this._debounceTimeout);
368
+ }
369
+ // Mark as loading right away, not just once the debounced request actually starts,
370
+ // otherwise the empty-results message flashes for the length of the debounce delay
371
+ // before there's been any real request to come back empty.
372
+ if (this.keyword.length > 0) {
373
+ this.loading = true;
374
+ }
375
+ this._debounceTimeout = setTimeout(() => {
376
+ this.loadData();
377
+ }, this.delay);
378
+ }
379
+
380
+ onSearchKeydown(e) {
381
+ if (!NAVIGATION_KEYS.includes(e.key) || !this.select) return;
382
+ this.select.handleExternalKeydown(e);
383
+ this._liveAnnouncement = this.select.highlightedText;
384
+ }
385
+
386
+ onSearchFocus() {
387
+ if (this.keyword.length > 0 && this.data.length > 0) {
388
+ this.select?.open();
389
+ }
390
+ }
391
+
392
+ onSearchFocusOut(e) {
393
+ if (!this.shadowRoot.contains(e.relatedTarget)) {
394
+ this.select?.close();
395
+ }
396
+ }
397
+
398
+ onIconClick() {
399
+ if (!this.keyword.length) return;
400
+ this.keyword = '';
401
+ this.data = [];
402
+ this.select?.close();
403
+ this.search.focus();
404
+ }
405
+
406
+ hideErrorOnInteraction() {
407
+ if (this.hideErrorOnInput && this.errored) {
408
+ this.clearError();
409
+ }
410
+ }
411
+
412
+ loadData() {
413
+ this.loading = true;
414
+ this.ajaxError = false;
415
+ const url = this.computeRequestURL();
416
+
417
+ fetch(url)
418
+ .then(response => response.json())
419
+ .then(json => this.registerData(json))
420
+ .catch(error => this.registerError(error));
421
+ }
422
+
423
+ computeRequestURL() {
424
+ const baseUrl = `${this.endpoint}?${this.queryStringVariable}=${this.keyword}`;
425
+
426
+ if (this.additionalQueryString && typeof this.additionalQueryString === 'object') {
427
+ const additionalParams = Object.entries(this.additionalQueryString)
428
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
429
+ .join('&');
430
+
431
+ return `${baseUrl}&${additionalParams}`;
432
+ }
433
+
434
+ return baseUrl;
435
+ }
436
+
437
+ registerError(err) {
438
+ this.ajaxError = true;
439
+ this.loading = false;
440
+ }
441
+
442
+ registerData(json) {
443
+ this.data = this.getResultData(json);
444
+ this.updateComplete.then(() => {
445
+ this.loading = false;
446
+ if (this.keyword.length > 0 && this.data.length > 0) {
447
+ this.select?.open();
448
+ }
449
+ });
450
+ }
451
+
452
+ getResultData(json) {
453
+ if (this.resultDataProperty === undefined || this.resultDataProperty === '') {
454
+ return json;
455
+ } else {
456
+ return json[this.resultDataProperty];
457
+ }
458
+ }
459
+
460
+ doSelected(e) {
461
+ e.stopPropagation();
462
+ let value = e.detail.value;
463
+ // The nested dile-select-overlay also fires element-changed with an empty value while
464
+ // syncing itself to its placeholder option on mount (before any real selection) since,
465
+ // unlike the original dile-select, it's kept mounted from the start so its MutationObserver
466
+ // is ready to pick up results as soon as they load. Ignore that non-interaction here.
467
+ if(e.detail.name === 'generated-select-field' && value !== '') {
468
+ this.selectedText = this.select.getOptionByValue(value).innerText;
469
+ this.value = value;
470
+ this.isSelected = true;
471
+ this.hideErrorOnInteraction();
472
+ }
473
+ }
474
+
475
+ clearError() {
476
+ this.errored = false;
477
+ this.message = '';
478
+ }
479
+
480
+ onClearSelected() {
481
+ this.value = undefined;
482
+ this.isSelected = false;
483
+ if(! this.static) {
484
+ this.keyword = '';
485
+ this.selectedText = '';
486
+ this.data = [];
487
+ this.updateComplete.then(() => {
488
+ this.search.focus();
489
+ });
490
+ }
491
+ this.hideErrorOnInteraction();
492
+ }
493
+
494
+ close() {
495
+ this.select?.close();
496
+ }
497
+
498
+ clear() {
499
+ this.isSelected = false;
500
+ this.value = undefined;
501
+ this.selectedText = '';
502
+ // isSelected flipping back to false remounts a brand new nested dile-select-overlay (the
503
+ // read-only and search branches are mutually exclusive templates). Its firstUpdated() syncs
504
+ // its value to the native <select>'s current value, and this component's <select> has no
505
+ // placeholder option — so leftover data/keyword from the previous search would leave a real
506
+ // option in place, get auto-picked as the new instance's value, and re-fire element-changed
507
+ // for the very same item that was just cleared (an infinite loop under addOnSelect).
508
+ if (!this.static) {
509
+ this.keyword = '';
510
+ this.data = [];
511
+ }
512
+ }
513
+
514
+ set(value) {
515
+ this.value = value;
516
+ if(value) {
517
+ this.isSelected = true;
518
+ this.searchValueInitial();
519
+ }
520
+ }
521
+
522
+ dispatchSelectedTextChanged() {
523
+ this.dispatchEvent(new CustomEvent('dile-select-ajax-selected-text-changed',
524
+ {
525
+ bubbles: true,
526
+ composed: true,
527
+ detail: { selectedText: this.selectedText }
528
+ }
529
+ ));
530
+ }
531
+ }