@dile/ui 3.18.5 → 3.19.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.
- package/components/floating-menu/src/DileFloatingMenu.js +1 -6
- package/components/input/src/DileInput.js +10 -0
- package/components/input/src/DileInputContact.js +2 -0
- package/components/input/src/DileInputIcon.js +2 -0
- package/components/list/index.js +2 -0
- package/components/list/list-item.js +5 -0
- package/components/list/list.js +6 -0
- package/components/list/src/DileList.js +606 -0
- package/components/list/src/DileListItem.js +499 -0
- package/components/select/src/DileSelect.js +37 -8
- package/components/select/src/DileSelectOverlay.js +41 -7
- package/components/select/src/DileSelectSearch.js +3 -1
- package/components/select/src/DileSelectSearchableBase.js +44 -0
- package/mixins/close-document-click/src/DileCloseDocumentClick.js +10 -1
- package/mixins/overlay/src/DileOverlay.js +38 -10
- package/package.json +2 -2
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
3
|
+
import '@dile/iconlib/dile-iconlib.js';
|
|
4
|
+
|
|
5
|
+
const HOST_FOCUS_ROLES = ['option', 'menuitem'];
|
|
6
|
+
const INTERACTIVE_TAGS = ['button', 'input', 'select', 'textarea', 'a', 'label', 'summary', 'dile-list-item'];
|
|
7
|
+
const INTERACTIVE_ROLES = ['button', 'link', 'checkbox', 'switch', 'radio', 'menuitem', 'option', 'tab', 'textbox'];
|
|
8
|
+
|
|
9
|
+
export class DileListItem extends LitElement {
|
|
10
|
+
static get styles() {
|
|
11
|
+
return css`
|
|
12
|
+
:host {
|
|
13
|
+
display: block;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
outline: none;
|
|
16
|
+
--_padding-y: var(--dile-list-item-padding-y, 0.75rem);
|
|
17
|
+
--_padding-x: var(--dile-list-item-padding-x, 1rem);
|
|
18
|
+
--_gap: var(--dile-list-item-gap, 0.75rem);
|
|
19
|
+
--_align: center;
|
|
20
|
+
--_divider-border: var(--dile-list-item-divider-width, 1px) var(--dile-list-item-divider-style, solid) var(--dile-list-item-divider-color, #e5e7eb);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
* {
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
:host([density="compact"]) {
|
|
28
|
+
--_padding-y: var(--dile-list-item-padding-y, 0.375rem);
|
|
29
|
+
--_padding-x: var(--dile-list-item-padding-x, 0.75rem);
|
|
30
|
+
--_gap: var(--dile-list-item-gap, 0.5rem);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
:host([density="comfortable"]) {
|
|
34
|
+
--_padding-y: var(--dile-list-item-padding-y, 0.75rem);
|
|
35
|
+
--_padding-x: var(--dile-list-item-padding-x, 1rem);
|
|
36
|
+
--_gap: var(--dile-list-item-gap, 0.75rem);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
:host([density="spacious"]) {
|
|
40
|
+
--_padding-y: var(--dile-list-item-padding-y, 1.25rem);
|
|
41
|
+
--_padding-x: var(--dile-list-item-padding-x, 1.25rem);
|
|
42
|
+
--_gap: var(--dile-list-item-gap, 1rem);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
:host([align="start"]) {
|
|
46
|
+
--_align: flex-start;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
:host([align="center"]) {
|
|
50
|
+
--_align: center;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:host([align="end"]) {
|
|
54
|
+
--_align: flex-end;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
:host([divider]:not([divider="inset"]):not([divider="none"])) {
|
|
58
|
+
border-bottom: var(--_divider-border);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.item {
|
|
62
|
+
display: flex;
|
|
63
|
+
box-sizing: border-box;
|
|
64
|
+
width: 100%;
|
|
65
|
+
margin: 0;
|
|
66
|
+
text-decoration: none;
|
|
67
|
+
color: var(--dile-list-item-color, inherit);
|
|
68
|
+
background-color: var(--dile-list-item-bg, transparent);
|
|
69
|
+
border-radius: var(--dile-list-item-border-radius, 4px);
|
|
70
|
+
padding-top: var(--_padding-y);
|
|
71
|
+
padding-bottom: var(--_padding-y);
|
|
72
|
+
padding-left: var(--_padding-x);
|
|
73
|
+
padding-right: var(--_padding-x);
|
|
74
|
+
gap: var(--_gap);
|
|
75
|
+
align-items: stretch;
|
|
76
|
+
transition-property: background-color, color, border-color, box-shadow;
|
|
77
|
+
transition-duration: var(--dile-list-item-transition-duration, 0.15s);
|
|
78
|
+
transition-timing-function: ease;
|
|
79
|
+
outline: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.item:focus-visible,
|
|
83
|
+
:host(:focus-visible) .item {
|
|
84
|
+
outline: var(--dile-list-item-focus-ring-width, 2px) solid var(--dile-list-item-focus-ring-color, #0284c7);
|
|
85
|
+
outline-offset: var(--dile-list-item-focus-ring-offset, -2px);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
:host([clickable]:not([disabled])) .item,
|
|
89
|
+
:host([href]:not([href=""]):not([disabled])) .item {
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
user-select: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
:host([clickable]:not([disabled])) .item:hover,
|
|
95
|
+
:host([href]:not([href=""]):not([disabled])) .item:hover {
|
|
96
|
+
background-color: var(--dile-list-item-bg-hover, var(--dile-surface-hover-color, #f5f5f5));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
:host([clickable]:not([disabled])) .item:active,
|
|
100
|
+
:host([href]:not([href=""]):not([disabled])) .item:active {
|
|
101
|
+
background-color: var(--dile-list-item-bg-active, var(--dile-surface-active-color, #ebebeb));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
:host([selected]) .item {
|
|
105
|
+
background-color: var(--dile-list-item-bg-selected, #e0f2fe);
|
|
106
|
+
color: var(--dile-list-item-selected-text-color, var(--dile-primary-dark-color, #0369a1));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
:host([disabled]) {
|
|
110
|
+
opacity: var(--dile-list-item-disabled-opacity, 0.5);
|
|
111
|
+
cursor: not-allowed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
:host([disabled]) .item {
|
|
115
|
+
pointer-events: none;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.prefix {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
flex-shrink: 0;
|
|
123
|
+
align-self: var(--dile-list-item-prefix-align, var(--_align));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.prefix[hidden],
|
|
127
|
+
.suffix[hidden] {
|
|
128
|
+
display: none !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.prefix dile-iconlib,
|
|
132
|
+
.suffix dile-iconlib {
|
|
133
|
+
--dile-icon-size: var(--dile-list-item-icon-size, 1.25rem);
|
|
134
|
+
--dile-icon-color: var(--dile-list-item-icon-color, currentColor);
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
justify-content: center;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
:host([selected]) .prefix dile-iconlib,
|
|
141
|
+
:host([selected]) .suffix dile-iconlib {
|
|
142
|
+
--dile-icon-color: var(--dile-list-item-selected-icon-color, var(--dile-list-item-selected-text-color, currentColor));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.content {
|
|
146
|
+
display: flex;
|
|
147
|
+
flex: 1 1 auto;
|
|
148
|
+
min-width: 0;
|
|
149
|
+
align-items: stretch;
|
|
150
|
+
gap: var(--_gap);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
:host([divider="inset"]) .content {
|
|
154
|
+
border-bottom: var(--_divider-border);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.body {
|
|
158
|
+
flex: 1 1 auto;
|
|
159
|
+
min-width: 0;
|
|
160
|
+
align-self: center;
|
|
161
|
+
text-align: var(--dile-list-item-text-align, left);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
:host([body-align="left"]) .body {
|
|
165
|
+
text-align: left;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
:host([body-align="center"]) .body {
|
|
169
|
+
text-align: center;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
:host([body-align="right"]) .body {
|
|
173
|
+
text-align: right;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.body ::slotted(p),
|
|
177
|
+
.body ::slotted(h1),
|
|
178
|
+
.body ::slotted(h2),
|
|
179
|
+
.body ::slotted(h3),
|
|
180
|
+
.body ::slotted(h4),
|
|
181
|
+
.body ::slotted(h5),
|
|
182
|
+
.body ::slotted(h6) {
|
|
183
|
+
margin-top: 0;
|
|
184
|
+
margin-bottom: 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
:host([lines="1"]) .body {
|
|
188
|
+
display: -webkit-box;
|
|
189
|
+
-webkit-line-clamp: 1;
|
|
190
|
+
-webkit-box-orient: vertical;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
text-overflow: ellipsis;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
:host([lines="2"]) .body {
|
|
196
|
+
display: -webkit-box;
|
|
197
|
+
-webkit-line-clamp: 2;
|
|
198
|
+
-webkit-box-orient: vertical;
|
|
199
|
+
overflow: hidden;
|
|
200
|
+
text-overflow: ellipsis;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
:host([lines]:not([lines="1"]):not([lines="2"]):not([lines="auto"]):not([lines=""])) .body {
|
|
204
|
+
display: -webkit-box;
|
|
205
|
+
-webkit-line-clamp: var(--_lines, 1);
|
|
206
|
+
-webkit-box-orient: vertical;
|
|
207
|
+
overflow: hidden;
|
|
208
|
+
text-overflow: ellipsis;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.suffix {
|
|
212
|
+
display: flex;
|
|
213
|
+
align-items: center;
|
|
214
|
+
justify-content: center;
|
|
215
|
+
flex-shrink: 0;
|
|
216
|
+
align-self: var(--dile-list-item-suffix-align, var(--_align));
|
|
217
|
+
}
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
static get properties() {
|
|
222
|
+
return {
|
|
223
|
+
/** Icon identifier in "family.name" format for prefix */
|
|
224
|
+
iconPrefix: { type: String, attribute: 'icon-prefix' },
|
|
225
|
+
/** Icon identifier in "family.name" format for suffix */
|
|
226
|
+
iconSuffix: { type: String, attribute: 'icon-suffix' },
|
|
227
|
+
/** Layout density: "compact", "comfortable", or "spacious". Unset behaves as "comfortable" */
|
|
228
|
+
density: { type: String, reflect: true },
|
|
229
|
+
/** Line clamping: "1", "2", "auto", or number. Unset behaves as "auto" */
|
|
230
|
+
lines: { type: String, reflect: true },
|
|
231
|
+
/** Vertical alignment of prefix and suffix: "center", "start", or "end". Unset behaves as "center" */
|
|
232
|
+
align: { type: String, reflect: true },
|
|
233
|
+
/** Text alignment of the body content: "left", "center", or "right". Unset uses --dile-list-item-text-align */
|
|
234
|
+
bodyAlign: { type: String, attribute: 'body-align', reflect: true },
|
|
235
|
+
/** Bottom separator: boolean attribute (full), "full", "inset" or "none". Unset means no divider */
|
|
236
|
+
divider: { type: String, reflect: true },
|
|
237
|
+
/** Whether item is interactive */
|
|
238
|
+
clickable: { type: Boolean, reflect: true },
|
|
239
|
+
/** Navigation URL */
|
|
240
|
+
href: { type: String, reflect: true },
|
|
241
|
+
/** Link target */
|
|
242
|
+
target: { type: String, reflect: true },
|
|
243
|
+
/** Disabled state */
|
|
244
|
+
disabled: { type: Boolean, reflect: true },
|
|
245
|
+
/** Selected state */
|
|
246
|
+
selected: { type: Boolean, reflect: true },
|
|
247
|
+
/** Expanded state */
|
|
248
|
+
expanded: { type: Boolean, reflect: true },
|
|
249
|
+
/** Value associated with the item */
|
|
250
|
+
value: { type: String, reflect: true },
|
|
251
|
+
/** Tabindex for roving tabindex control */
|
|
252
|
+
itemTabindex: { type: Number, attribute: 'item-tabindex' },
|
|
253
|
+
/** Mirrors the host role attribute so the template reacts to role changes */
|
|
254
|
+
_hostRole: { attribute: 'role' },
|
|
255
|
+
/** Internal slot states */
|
|
256
|
+
_hasPrefixSlot: { state: true },
|
|
257
|
+
_hasSuffixSlot: { state: true },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
constructor() {
|
|
262
|
+
super();
|
|
263
|
+
this.clickable = false;
|
|
264
|
+
this.disabled = false;
|
|
265
|
+
this.selected = false;
|
|
266
|
+
this.itemTabindex = 0;
|
|
267
|
+
this._hasPrefixSlot = false;
|
|
268
|
+
this._hasSuffixSlot = false;
|
|
269
|
+
this._managesHostAria = false;
|
|
270
|
+
|
|
271
|
+
this._handleKeyDown = this._handleKeyDown.bind(this);
|
|
272
|
+
this._handleKeyUp = this._handleKeyUp.bind(this);
|
|
273
|
+
this.addEventListener('keydown', this._handleKeyDown);
|
|
274
|
+
this.addEventListener('keyup', this._handleKeyUp);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
connectedCallback() {
|
|
278
|
+
super.connectedCallback();
|
|
279
|
+
if (!this.hasAttribute('role')) {
|
|
280
|
+
this.setAttribute('role', 'listitem');
|
|
281
|
+
}
|
|
282
|
+
this._checkLightDomSlots();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
updated(changedProperties) {
|
|
286
|
+
super.updated(changedProperties);
|
|
287
|
+
if (changedProperties.has('lines')) {
|
|
288
|
+
if (this.lines && this.lines !== 'auto' && this.lines !== '1' && this.lines !== '2') {
|
|
289
|
+
this.style.setProperty('--_lines', String(this.lines));
|
|
290
|
+
} else {
|
|
291
|
+
this.style.removeProperty('--_lines');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
this._syncHostAria();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** When the host acts as an option / menuitem it is the focusable, ARIA-bearing element. */
|
|
298
|
+
_isHostFocusMode() {
|
|
299
|
+
return HOST_FOCUS_ROLES.includes(this._hostRole);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_isInteractive() {
|
|
303
|
+
return Boolean(this.clickable || this.href || this._isHostFocusMode());
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
_computedTabindex() {
|
|
307
|
+
if (this.disabled) return -1;
|
|
308
|
+
return this.itemTabindex !== undefined && this.itemTabindex !== null ? this.itemTabindex : 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_expandedValue() {
|
|
312
|
+
return this.expanded !== undefined ? String(Boolean(this.expanded)) : undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_syncHostAria() {
|
|
316
|
+
if (this._isHostFocusMode()) {
|
|
317
|
+
this.setAttribute('tabindex', String(this._computedTabindex()));
|
|
318
|
+
if (this._hostRole === 'option') {
|
|
319
|
+
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
|
|
320
|
+
} else {
|
|
321
|
+
this.removeAttribute('aria-selected');
|
|
322
|
+
}
|
|
323
|
+
this._toggleHostAttribute('aria-disabled', this.disabled ? 'true' : undefined);
|
|
324
|
+
this._toggleHostAttribute('aria-expanded', this._expandedValue());
|
|
325
|
+
this._managesHostAria = true;
|
|
326
|
+
} else if (this._managesHostAria) {
|
|
327
|
+
['tabindex', 'aria-selected', 'aria-disabled', 'aria-expanded'].forEach((attr) => this.removeAttribute(attr));
|
|
328
|
+
this._managesHostAria = false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_toggleHostAttribute(name, value) {
|
|
333
|
+
if (value === undefined) {
|
|
334
|
+
this.removeAttribute(name);
|
|
335
|
+
} else {
|
|
336
|
+
this.setAttribute(name, value);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_checkLightDomSlots() {
|
|
341
|
+
this._hasPrefixSlot = Boolean(this.querySelector(':scope > [slot="prefix"], :scope > [slot="leading"]'));
|
|
342
|
+
this._hasSuffixSlot = Boolean(this.querySelector(':scope > [slot="suffix"], :scope > [slot="trailing"]'));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_handleSlotChange() {
|
|
346
|
+
this._checkLightDomSlots();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
focus(options) {
|
|
350
|
+
if (this._isHostFocusMode()) {
|
|
351
|
+
super.focus(options);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const target = this.shadowRoot?.querySelector('.item');
|
|
355
|
+
if (target) {
|
|
356
|
+
target.focus(options);
|
|
357
|
+
} else {
|
|
358
|
+
super.focus(options);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
render() {
|
|
363
|
+
const hasPrefix = Boolean(this._hasPrefixSlot || this.iconPrefix);
|
|
364
|
+
const hasSuffix = Boolean(this._hasSuffixSlot || this.iconSuffix);
|
|
365
|
+
const hostFocus = this._isHostFocusMode();
|
|
366
|
+
// The inner element carries focus and ARIA state unless the host is an option / menuitem.
|
|
367
|
+
const innerFocusable = this._isInteractive() && !hostFocus;
|
|
368
|
+
const tabindex = this._computedTabindex();
|
|
369
|
+
const innerDisabled = ifDefined(innerFocusable && this.disabled ? 'true' : undefined);
|
|
370
|
+
const innerCurrent = ifDefined(innerFocusable && this.selected ? 'true' : undefined);
|
|
371
|
+
const innerExpanded = ifDefined(innerFocusable ? this._expandedValue() : undefined);
|
|
372
|
+
|
|
373
|
+
const contentTemplate = html`
|
|
374
|
+
<div class="prefix" part="prefix" ?hidden="${!hasPrefix}">
|
|
375
|
+
<slot name="prefix" @slotchange="${this._handleSlotChange}"><slot name="leading" @slotchange="${this._handleSlotChange}">${this.iconPrefix ? html`<dile-iconlib icon="${this.iconPrefix}" aria-hidden="true"></dile-iconlib>` : ''}</slot></slot>
|
|
376
|
+
</div>
|
|
377
|
+
<div class="content" part="content">
|
|
378
|
+
<div class="body" part="body">
|
|
379
|
+
<slot></slot>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="suffix" part="suffix" ?hidden="${!hasSuffix}">
|
|
382
|
+
<slot name="suffix" @slotchange="${this._handleSlotChange}"><slot name="trailing" @slotchange="${this._handleSlotChange}">${this.iconSuffix ? html`<dile-iconlib icon="${this.iconSuffix}" aria-hidden="true"></dile-iconlib>` : ''}</slot></slot>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
`;
|
|
386
|
+
|
|
387
|
+
if (this.href) {
|
|
388
|
+
return html`
|
|
389
|
+
<a
|
|
390
|
+
class="item"
|
|
391
|
+
part="item"
|
|
392
|
+
href="${ifDefined(this.disabled ? undefined : this.href)}"
|
|
393
|
+
target="${ifDefined(this.target || undefined)}"
|
|
394
|
+
rel="${ifDefined(this.target === '_blank' ? 'noopener noreferrer' : undefined)}"
|
|
395
|
+
tabindex="${hostFocus ? -1 : tabindex}"
|
|
396
|
+
aria-disabled="${innerDisabled}"
|
|
397
|
+
aria-current="${innerCurrent}"
|
|
398
|
+
aria-expanded="${innerExpanded}"
|
|
399
|
+
@click="${this._handleItemClick}"
|
|
400
|
+
>${contentTemplate}</a>
|
|
401
|
+
`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return html`
|
|
405
|
+
<div
|
|
406
|
+
class="item"
|
|
407
|
+
part="item"
|
|
408
|
+
role="${ifDefined(innerFocusable ? 'button' : undefined)}"
|
|
409
|
+
tabindex="${ifDefined(innerFocusable ? tabindex : undefined)}"
|
|
410
|
+
aria-disabled="${innerDisabled}"
|
|
411
|
+
aria-current="${innerCurrent}"
|
|
412
|
+
aria-expanded="${innerExpanded}"
|
|
413
|
+
@click="${this._handleItemClick}"
|
|
414
|
+
>${contentTemplate}</div>
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Returns true when the event started on an interactive control placed inside the item
|
|
420
|
+
* (slotted buttons, inputs, links, nested list items...). Those controls own their events.
|
|
421
|
+
*/
|
|
422
|
+
_isFromNestedControl(e) {
|
|
423
|
+
const stop = this.shadowRoot.querySelector('.item');
|
|
424
|
+
for (const el of e.composedPath()) {
|
|
425
|
+
if (el === stop || el === this) return false;
|
|
426
|
+
if (!(el instanceof Element)) continue;
|
|
427
|
+
const tag = el.localName;
|
|
428
|
+
const role = el.getAttribute('role');
|
|
429
|
+
if (
|
|
430
|
+
INTERACTIVE_TAGS.includes(tag) ||
|
|
431
|
+
INTERACTIVE_ROLES.includes(role) ||
|
|
432
|
+
el.isContentEditable ||
|
|
433
|
+
(el.hasAttribute('tabindex') && el.tabIndex >= 0)
|
|
434
|
+
) {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** True when the event originated on the item's own focus target (host or inner .item). */
|
|
442
|
+
_isOwnFocusTarget(e) {
|
|
443
|
+
const origin = e.composedPath()[0];
|
|
444
|
+
return origin === this || origin === this.shadowRoot?.querySelector('.item');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_handleItemClick(e) {
|
|
448
|
+
if (!this._isInteractive()) return;
|
|
449
|
+
if (this.disabled) {
|
|
450
|
+
e.preventDefault();
|
|
451
|
+
e.stopPropagation();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (this._isFromNestedControl(e)) return;
|
|
455
|
+
this._dispatchItemClick();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_dispatchItemClick() {
|
|
459
|
+
this.dispatchEvent(new CustomEvent('dile-list-item-click', {
|
|
460
|
+
bubbles: true,
|
|
461
|
+
composed: true,
|
|
462
|
+
detail: {
|
|
463
|
+
item: this,
|
|
464
|
+
value: this.value,
|
|
465
|
+
href: this.href,
|
|
466
|
+
selected: this.selected,
|
|
467
|
+
}
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
_handleKeyDown(e) {
|
|
472
|
+
if (this.disabled || !this._isInteractive() || !this._isOwnFocusTarget(e)) return;
|
|
473
|
+
if (e.key === 'Enter') {
|
|
474
|
+
if (!this.href) {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
this._dispatchItemClick();
|
|
477
|
+
} else if (e.composedPath()[0] === this) {
|
|
478
|
+
// Host has focus (option / menuitem mode): the native anchor won't receive the Enter key.
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
this.shadowRoot.querySelector('a')?.click();
|
|
481
|
+
}
|
|
482
|
+
} else if (e.key === ' ') {
|
|
483
|
+
// Prevent the page from scrolling; activation happens on keyup (button behaviour).
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
_handleKeyUp(e) {
|
|
489
|
+
if (this.disabled || !this._isInteractive() || !this._isOwnFocusTarget(e)) return;
|
|
490
|
+
if (e.key === ' ') {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
if (this.href) {
|
|
493
|
+
this.shadowRoot.querySelector('a')?.click();
|
|
494
|
+
} else {
|
|
495
|
+
this._dispatchItemClick();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
@@ -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
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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) =>
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
>${
|
|
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) {
|