@dile/ui 3.19.0 → 3.19.2

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.
@@ -243,12 +243,7 @@ export class DileFloatingMenu extends DileOverlay(DileCloseOnEscPressed(LitEleme
243
243
  }
244
244
 
245
245
  _hideOverlay() {
246
- // Closed during the opening delay of DileOverlay, where its close() would be ignored.
247
- if (this._opening) {
248
- this.cancelDelay();
249
- this._opening = false;
250
- this._overlayClass = 'opened';
251
- }
246
+ // DileOverlay's close() also cancels an opening still in its delay.
252
247
  super.close();
253
248
  }
254
249
 
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
4
  import { labelStyles } from './label-styles.js';
4
5
  import { messageStyles } from './message-styles.js';
@@ -22,6 +23,9 @@ export class DileInput extends DileEmmitChange(LitElement) {
22
23
  /** Label to the element */
23
24
  label: { type: String },
24
25
 
26
+ /** Accessible name of the input, applied as aria-label only when there is no visible label */
27
+ accessibleLabel: { type: String },
28
+
25
29
  /** Input type */
26
30
  type: { type: String },
27
31
 
@@ -166,6 +170,7 @@ export class DileInput extends DileEmmitChange(LitElement) {
166
170
  type="${this.availableType(this.type)}"
167
171
  id="textField"
168
172
  name="${this.name}"
173
+ aria-label="${ifDefined(this.inputAriaLabel)}"
169
174
  placeholder="${this.placeholder}"
170
175
  ?disabled="${this.disabled}"
171
176
  ?readonly="${this.readonly}"
@@ -187,6 +192,11 @@ export class DileInput extends DileEmmitChange(LitElement) {
187
192
  `;
188
193
  }
189
194
 
195
+ /** aria-label for the inner input: accessibleLabel, only when there is no visible label */
196
+ get inputAriaLabel() {
197
+ return !this.label && this.accessibleLabel ? this.accessibleLabel : undefined;
198
+ }
199
+
190
200
  get messageTemplate() {
191
201
  return html`
192
202
  ${this.message
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
  import { DileInput } from '../../input/index.js';
3
4
 
4
5
  export class DileInputContact extends DileInput {
@@ -48,6 +49,7 @@ export class DileInputContact extends DileInput {
48
49
  type="${this.availableType(this.type)}"
49
50
  id="textField"
50
51
  name="${this.name}"
52
+ aria-label="${ifDefined(this.inputAriaLabel)}"
51
53
  placeholder="${this.placeholder}"
52
54
  ?disabled="${this.disabled}"
53
55
  ?readonly="${this.readonly}"
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
  import { DileInput } from '../../input/index.js';
3
4
  import '@dile/iconlib/dile-iconlib.js';
4
5
  import '@dile/ui/components/tooltip/tooltip.js';
@@ -100,6 +101,7 @@ export class DileInputIcon extends DileInput {
100
101
  type="${this.availableType(this.type)}"
101
102
  id="textField"
102
103
  name="${this.name}"
104
+ aria-label="${ifDefined(this.inputAriaLabel)}"
103
105
  placeholder="${this.placeholder}"
104
106
  ?disabled="${this.disabled}"
105
107
  ?readonly="${this.readonly}"
@@ -97,6 +97,8 @@ export class DileSelect extends DileEmmitChange(LitElement) {
97
97
  static get properties() {
98
98
  return {
99
99
  label: { type: String },
100
+ /** Accessible name of the slotted select when there is no visible label. With label, the label text is used */
101
+ accessibleLabel: { type: String },
100
102
  value: { type: String },
101
103
  name: { type: String, reflect: true },
102
104
  disabled: { type: Boolean },
@@ -114,7 +116,7 @@ export class DileSelect extends DileEmmitChange(LitElement) {
114
116
  return html`
115
117
  <div>
116
118
  ${this.label
117
- ? html`<label for="${this.selectId}">${this.label}</label>`
119
+ ? html`<label for="${this.selectId}" @click="${this._focusSelect}">${this.label}</label>`
118
120
  : ""
119
121
  }
120
122
  <div class="select-wrapper">
@@ -159,6 +161,11 @@ export class DileSelect extends DileEmmitChange(LitElement) {
159
161
  if (!this.elselect.id) {
160
162
  this.elselect.id = this.selectId;
161
163
  }
164
+ // aria-label / aria-description set by the author on the slotted select always win
165
+ if (this._authorAriaLabel === undefined) {
166
+ this._authorAriaLabel = this.elselect.hasAttribute("aria-label");
167
+ this._authorAriaDescription = this.elselect.hasAttribute("aria-description");
168
+ }
162
169
  this.elselect.addEventListener("change", this.changeHandler);
163
170
  this.updateSelectA11y();
164
171
  }
@@ -194,7 +201,8 @@ export class DileSelect extends DileEmmitChange(LitElement) {
194
201
  this.elselect.classList.toggle("disabled", this.disabled);
195
202
  this.elselect.setAttribute("aria-disabled", this.disabled);
196
203
  }
197
- if(changedProperties.has("errored") || changedProperties.has("message")) {
204
+ if(changedProperties.has("errored") || changedProperties.has("message")
205
+ || changedProperties.has("label") || changedProperties.has("accessibleLabel")) {
198
206
  this.updateSelectA11y();
199
207
  }
200
208
  }
@@ -205,18 +213,39 @@ export class DileSelect extends DileEmmitChange(LitElement) {
205
213
  // Set aria-invalid when errored
206
214
  this.elselect.setAttribute("aria-invalid", this.errored);
207
215
 
208
- // Set aria-describedby when message exists
209
- const messageId = this.message ? `${this.selectId}-message` : null;
210
- if (messageId) {
211
- this.elselect.setAttribute("aria-describedby", messageId);
212
- } else {
213
- this.elselect.removeAttribute("aria-describedby");
216
+ // The message lives in the shadow DOM and the select in the light DOM, so an
217
+ // aria-describedby id reference cannot cross the boundary: use aria-description.
218
+ // aria-describedby is left to the author (e.g. help text in the light DOM).
219
+ if (!this._authorAriaDescription) {
220
+ if (this.message) {
221
+ this.elselect.setAttribute("aria-description", this.message);
222
+ } else {
223
+ this.elselect.removeAttribute("aria-description");
224
+ }
214
225
  }
215
226
 
227
+ // Accessible name. The <label for> lives in the shadow DOM and the select in the
228
+ // light DOM, so "for" cannot cross the boundary: name the select via aria-label.
229
+ if (!this._authorAriaLabel) {
230
+ const accessibleName = this.label || this.accessibleLabel;
231
+ if (accessibleName) {
232
+ this.elselect.setAttribute("aria-label", accessibleName);
233
+ } else {
234
+ this.elselect.removeAttribute("aria-label");
235
+ }
236
+ }
237
+
216
238
  // Update visual state
217
239
  this.elselect.classList.toggle("errored", this.errored);
218
240
  }
219
241
 
242
+ // <label for> cannot reach the slotted select, so replicate the native label click
243
+ _focusSelect() {
244
+ if (this.elselect && !this.disabled) {
245
+ this.elselect.focus();
246
+ }
247
+ }
248
+
220
249
  firstUpdated() {
221
250
  this.quiet = this.quietOnStart;
222
251
  if(this.value) {
@@ -150,6 +150,11 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
150
150
  .option[aria-selected="true"] {
151
151
  font-weight: var(--dile-select-overlay-selected-font-weight, 600);
152
152
  }
153
+ .option-content {
154
+ display: block;
155
+ flex: 1 1 auto;
156
+ min-width: 0;
157
+ }
153
158
  @media (forced-colors: active) {
154
159
  #trigger {
155
160
  border-width: var(--dile-input-border-width, 1px);
@@ -172,6 +177,8 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
172
177
  static get properties() {
173
178
  return {
174
179
  label: { type: String },
180
+ /** Accessible name of the combobox trigger and the listbox, applied as aria-label only when there is no visible label */
181
+ accessibleLabel: { type: String },
175
182
  value: { type: String },
176
183
  name: { type: String, reflect: true },
177
184
  disabled: { type: Boolean },
@@ -185,6 +192,12 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
185
192
  /** Suppresses the visible trigger box, letting a parent component drive open()/close()
186
193
  * and forward keyboard events via handleExternalKeydown() while supplying its own visible control. */
187
194
  hideTrigger: { type: Boolean, reflect: true },
195
+ /** Optional custom renderer for the content of each option in the popup:
196
+ * `(option, index) => TemplateResult | string`, where `option` is `{ value, text, icon, element }`
197
+ * and `element` is the native <option> (useful to read its dataset). The <li role="option">
198
+ * itself (ids, aria-selected, highlight, click) is still managed by the component, and the
199
+ * trigger, typeahead, accessible name and highlightedText keep using the plain option text. */
200
+ optionRenderer: { attribute: false },
188
201
  _activeIndex: { state: true },
189
202
  /** Display text of the currently highlighted option, readable by an external composer. */
190
203
  highlightedText: { state: true },
@@ -218,7 +231,7 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
218
231
 
219
232
  get options() {
220
233
  return this.elselect
221
- ? Array.from(this.elselect.options).map(o => ({ value: o.value, text: o.textContent, icon: o.dataset.icon || this.elselect.dataset.icon }))
234
+ ? Array.from(this.elselect.options).map(o => ({ value: o.value, text: o.textContent, icon: o.dataset.icon || this.elselect.dataset.icon, element: o }))
222
235
  : [];
223
236
  }
224
237
 
@@ -325,6 +338,11 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
325
338
  `;
326
339
  }
327
340
 
341
+ /** aria-label for the trigger and the listbox: accessibleLabel, only when there is no visible label */
342
+ get controlAriaLabel() {
343
+ return !this.label && this.accessibleLabel ? this.accessibleLabel : undefined;
344
+ }
345
+
328
346
  get triggerTemplate() {
329
347
  const selected = this.selectedOption;
330
348
  return html`
@@ -332,6 +350,7 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
332
350
  type="button"
333
351
  id="trigger"
334
352
  role="combobox"
353
+ aria-label="${ifDefined(this.controlAriaLabel)}"
335
354
  aria-haspopup="listbox"
336
355
  aria-expanded="${this.opened}"
337
356
  aria-controls="overlay"
@@ -360,21 +379,36 @@ export class DileSelectOverlay extends DileOverlay(DileCloseDocumentClick(DileEm
360
379
  get overlayTemplate() {
361
380
  const options = this.options;
362
381
  return html`
363
- <ul id="overlay" class="${this._overlayClass}" role="listbox">
364
- ${options.map((opt, index) => html`
382
+ <ul id="overlay" class="${this._overlayClass}" role="listbox" aria-label="${ifDefined(this.controlAriaLabel)}">
383
+ ${options.map((opt, index) => {
384
+ const highlighted = index === this._activeIndex;
385
+ const selected = opt.value === (this.value ?? '');
386
+ const parts = ['option', highlighted ? 'option-highlighted' : '', selected ? 'option-selected' : ''].filter(Boolean).join(' ');
387
+ return html`
365
388
  <li
366
389
  id="${this._optionId(index)}"
367
390
  role="option"
368
- class="option ${index === this._activeIndex ? 'highlighted' : ''}"
369
- aria-selected="${opt.value === (this.value ?? '')}"
391
+ part="${parts}"
392
+ class="option ${highlighted ? 'highlighted' : ''}"
393
+ aria-selected="${selected}"
394
+ aria-label="${ifDefined(this.optionRenderer ? opt.text.trim() : undefined)}"
370
395
  @click="${() => this.selectOption(index)}"
371
396
  @mousemove="${() => this._setActiveIndex(index, false)}"
372
- >${opt.icon ? html`<span class="option-icon">${this.renderIconlibIcon(opt.icon)}</span>` : ''}${opt.text}</li>
373
- `)}
397
+ >${this.optionContentTemplate(opt, index)}</li>
398
+ `;
399
+ })}
374
400
  </ul>
375
401
  `;
376
402
  }
377
403
 
404
+ /** Content of each <li role="option">: optionRenderer's output when set, icon + text otherwise. */
405
+ optionContentTemplate(opt, index) {
406
+ if (typeof this.optionRenderer === 'function') {
407
+ return html`<span class="option-content" part="option-content">${this.optionRenderer(opt, index)}</span>`;
408
+ }
409
+ return html`${opt.icon ? html`<span class="option-icon">${this.renderIconlibIcon(opt.icon)}</span>` : ''}${opt.text}`;
410
+ }
411
+
378
412
  renderIconlibIcon(icon) {
379
413
  const dotIndex = icon.indexOf('.');
380
414
  if (dotIndex === -1) {
@@ -65,6 +65,8 @@ export class DileSelectSearch extends DileSelectSearchableBase {
65
65
  value: option.value,
66
66
  text: option.textContent.trim(),
67
67
  icon: option.dataset.icon || select.dataset.icon,
68
+ // The native <option>, so an optionRenderer can read its dataset.
69
+ element: option,
68
70
  }));
69
71
  }
70
72
 
@@ -91,7 +93,7 @@ export class DileSelectSearch extends DileSelectSearchableBase {
91
93
  * Template method: decide whether an option matches the keyword.
92
94
  * By default it searches the keyword anywhere in the option text, ignoring case and accents.
93
95
  * Override it in a subclass to customize the search (e.g. also match option.value).
94
- * @param {{value: string, text: string, icon: string}} option
96
+ * @param {{value: string, text: string, icon: string, element: HTMLOptionElement}} option
95
97
  * @param {string} keyword
96
98
  * @returns {boolean}
97
99
  */
@@ -11,6 +11,8 @@ import { labelStyles } from '../../input/src/label-styles.js';
11
11
  import { messageStyles } from '../../input/src/message-styles.js';
12
12
 
13
13
  const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter', 'Escape', 'Tab'];
14
+ // Parts of the nested dile-select-overlay re-exported to this component's host.
15
+ const OPTION_PARTS = ['option', 'option-highlighted', 'option-selected', 'option-content'];
14
16
 
15
17
  /**
16
18
  * Base class for the searchable members of the select family (dile-select-ajax-overlay,
@@ -158,6 +160,13 @@ export class DileSelectSearchableBase extends DileEmmitChange(LitElement) {
158
160
  /** dile-iconlib "family.name" icon string applied to every option that has no icon of its
159
161
  * own via iconProperty. */
160
162
  icon: { type: String },
163
+ /** Optional custom renderer for the content of each result: `(item, index) => TemplateResult | string`,
164
+ * receiving the original data object. The displayProperty text is still used for the accessible
165
+ * name, the keyboard announcements and the selected text. */
166
+ optionRenderer: { attribute: false },
167
+ /** Space or comma separated extra part names used inside optionRenderer templates, re-exported
168
+ * from the nested dile-select-overlay so they can be styled with ::part() from outside. */
169
+ optionParts: { type: String },
161
170
  delay: { type: Number },
162
171
  keyword: {
163
172
  type: String,
@@ -382,6 +391,8 @@ export class DileSelectSearchableBase extends DileEmmitChange(LitElement) {
382
391
  name="generated-select-field"
383
392
  placeholder="${this.placeholder}"
384
393
  ?disabled=${this.disabled}
394
+ exportparts="${this.exportedOptionParts}"
395
+ .optionRenderer=${this.nestedOptionRenderer}
385
396
  @element-changed=${this.doSelected}
386
397
  >
387
398
  <select slot="select" data-icon="${ifDefined(this.icon)}">
@@ -393,6 +404,39 @@ export class DileSelectSearchableBase extends DileEmmitChange(LitElement) {
393
404
  `;
394
405
  }
395
406
 
407
+ /** exportparts value for the nested dile-select-overlay: built-in option parts plus optionParts. */
408
+ get exportedOptionParts() {
409
+ const extra = (this.optionParts || '').split(/[\s,]+/).filter(Boolean);
410
+ return [...new Set([...OPTION_PARTS, ...extra])].join(', ');
411
+ }
412
+
413
+ /** Adapts optionRenderer (which receives data items) to the nested overlay's (option, index) signature.
414
+ * Cached per optionRenderer so the nested component doesn't see a new function on every render. */
415
+ get nestedOptionRenderer() {
416
+ if (typeof this.optionRenderer !== 'function') {
417
+ return undefined;
418
+ }
419
+ if (this._nestedRendererFor !== this.optionRenderer) {
420
+ const renderer = this.optionRenderer;
421
+ this._nestedRendererFor = renderer;
422
+ this._nestedRenderer = (option, index) => {
423
+ const item = this.findItemForOption(option, index);
424
+ return item === undefined ? option.text : renderer(item, index);
425
+ };
426
+ }
427
+ return this._nestedRenderer;
428
+ }
429
+
430
+ /** Data item behind an option of the nested overlay: by index (options are generated from
431
+ * `data` in order), checked against idProperty, falling back to a search by idProperty. */
432
+ findItemForOption(option, index) {
433
+ const byIndex = this.data[index];
434
+ if (byIndex && String(byIndex[this.idProperty]) === option.value) {
435
+ return byIndex;
436
+ }
437
+ return this.data.find(item => String(item[this.idProperty]) === option.value);
438
+ }
439
+
396
440
  get messageTemplate() {
397
441
  return html`
398
442
  ${this.message
@@ -28,7 +28,7 @@ export const DileCloseDocumentClick = (SuperClass) =>
28
28
  DileCloseDocumentClick.closeDocumentHandler = () => {
29
29
  if(DileCloseDocumentClick.elements) {
30
30
  for (let ele of DileCloseDocumentClick.elements) {
31
- ele.close();
31
+ ele.closeFromDocumentClick();
32
32
  }
33
33
  }
34
34
  };
@@ -70,6 +70,15 @@ export const DileCloseDocumentClick = (SuperClass) =>
70
70
  });
71
71
  }
72
72
 
73
+ /**
74
+ * Close requested by a click in the document. Calls close() by default.
75
+ * Override it to ignore these closes in some states, as DileOverlay does
76
+ * while the overlay is opening.
77
+ */
78
+ closeFromDocumentClick() {
79
+ this.close();
80
+ }
81
+
73
82
  /**
74
83
  * This method only alerts the developer to create the close() method in his component
75
84
  */
@@ -69,7 +69,9 @@ export const DileForm = (superclass) => class extends superclass {
69
69
  node.value = value;
70
70
  }
71
71
  }
72
- } else if (!isArray && adaptedData[fieldName] !== undefined && adaptedData[fieldName] !== null) {
72
+ } else if (!isArray && adaptedData[fieldName] === null) {
73
+ this._clearNode(node);
74
+ } else if (!isArray && adaptedData[fieldName] !== undefined) {
73
75
  if (typeof node.set === "function") {
74
76
  node.set(adaptedData[fieldName]);
75
77
  } else {
@@ -80,13 +82,15 @@ export const DileForm = (superclass) => class extends superclass {
80
82
  }
81
83
 
82
84
  clearData() {
83
- this.allNodeElements.forEach(node => {
84
- if (typeof node.clear === "function") {
85
- node.clear();
86
- } else {
87
- node.value = '';
88
- }
89
- });
85
+ this.allNodeElements.forEach(node => this._clearNode(node));
86
+ }
87
+
88
+ _clearNode(node) {
89
+ if (typeof node.clear === "function") {
90
+ node.clear();
91
+ } else {
92
+ node.value = '';
93
+ }
90
94
  }
91
95
 
92
96
  get allNodeElements() {
@@ -143,11 +147,7 @@ export const DileForm = (superclass) => class extends superclass {
143
147
  clearField(name) {
144
148
  const node = this.getNodeElement(name);
145
149
  if (node) {
146
- if (typeof node.clear === "function") {
147
- node.clear();
148
- } else {
149
- node.value = '';
150
- }
150
+ this._clearNode(node);
151
151
  }
152
152
  }
153
153
 
@@ -80,35 +80,63 @@ export const DileOverlay = function(superClass) {
80
80
  }
81
81
  /**
82
82
  * closes the overlay
83
+ *
84
+ * An explicit close() while the overlay is still opening (the 50ms delay of open())
85
+ * cancels the opening, so the overlay ends up closed.
83
86
  */
84
87
  close() {
85
- if(!this._opening && this._overlayClass=='opened') {
88
+ if(this._opening) {
89
+ this.cancelDelay();
90
+ this._opening = false;
91
+ this._overlayClass = '';
92
+ this.overlay.style.display = 'none';
93
+ this._dispatchClosed();
94
+ } else if(this._overlayClass=='opened') {
86
95
  this._overlayClass = '';
87
96
  this._removePositionListeners();
88
97
  this.cancelDelay();
89
98
  this.delayId = setTimeout(() => {
90
99
  this.overlay.style.display = 'none'
91
100
  }, 500);
92
- this.dispatchEvent(new CustomEvent('overlay-closed', {
101
+ this._dispatchClosed();
102
+ }
103
+ }
104
+ /**
105
+ * Close requested by a document click (DileCloseDocumentClick). It is ignored while
106
+ * the overlay is opening, because the same click that opens the overlay bubbles up
107
+ * to the document.
108
+ */
109
+ closeFromDocumentClick() {
110
+ if(!this._opening) {
111
+ this.close();
112
+ }
113
+ }
114
+ _dispatchClosed() {
115
+ this.dispatchEvent(new CustomEvent('overlay-closed', {
93
116
  bubbles: true,
94
117
  composed: true,
95
118
  }));
96
- }
97
119
  }
98
120
  /**
99
121
  * opens the overlay
122
+ *
123
+ * overlay-opened is only dispatched when the overlay was closed, so every
124
+ * overlay-opened is paired with one overlay-closed.
100
125
  */
101
126
  open() {
102
- this.dispatchEvent(new CustomEvent('overlay-opened', {
103
- bubbles: true,
104
- composed: true,
105
- }));
127
+ if(!this._opening && this._overlayClass != 'opened') {
128
+ this.dispatchEvent(new CustomEvent('overlay-opened', {
129
+ bubbles: true,
130
+ composed: true,
131
+ }));
132
+ }
106
133
  if(!this._opening) {
107
134
  this._opening = true;
108
135
  this.updatePosition();
109
- if(this.closeAll) {
110
- this.closeAll();
111
- };
136
+ // Close the other overlays, even if they are still opening.
137
+ if(this.closeOthers) {
138
+ this.closeOthers();
139
+ }
112
140
  this.overlay.style.display = 'block';
113
141
  this.cancelDelay();
114
142
  this.delayId = setTimeout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "3.19.0",
3
+ "version": "3.19.2",
4
4
  "description": "UI Core components from dile-components.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,5 +28,5 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "gitHead": "7cf15a5a6e19765b52bebf5902646c8b5e2e67f0"
31
+ "gitHead": "545b8f87465a4567904c577699562f3c5f676e68"
32
32
  }