@dile/ui 3.18.1 → 3.18.3
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 +195 -61
- package/components/floating-menu/src/DileFloatingMenuItem.js +12 -5
- package/components/menu-overlay/src/DileMenuOverlay.js +50 -0
- package/components/selector/src/DileSelectorItem.js +65 -20
- package/components/selector/src/DileSelectorOverlay.js +92 -5
- package/components/tabs/src/DileTab.js +70 -16
- package/components/tabs/src/DileTabs.js +63 -0
- package/package.json +2 -2
|
@@ -1,47 +1,41 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { DileOverlay } from '../../../mixins/overlay/index.js';
|
|
3
|
+
import { DileCloseOnEscPressed } from '../../../mixins/close-on-esc-pressed/index.js';
|
|
2
4
|
import './DileFloatingMenuItem.js';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
// Distance between the floating button and the overlay.
|
|
7
|
+
const OVERLAY_GAP = 10;
|
|
8
|
+
|
|
9
|
+
// DileOverlay places the overlay next to the button and keeps it inside the viewport.
|
|
10
|
+
// The opened property is the source of truth: open(), close() and toggle() only change it.
|
|
11
|
+
export class DileFloatingMenu extends DileOverlay(DileCloseOnEscPressed(LitElement)) {
|
|
5
12
|
static styles = [
|
|
6
13
|
css`
|
|
7
14
|
:host {
|
|
8
|
-
|
|
9
|
-
--dile-floating-menu-margin-bottom: var(--dile-floating-menu-margin-bottom);
|
|
10
|
-
--dile-floating-menu-margin-top: var(--dile-floating-menu-margin-top);
|
|
11
|
-
--dile-floating-menu-margin-left: var(--dile-floating-menu-margin-left);
|
|
12
|
-
--dile-floating-menu-margin-right: var(--dile-floating-menu-margin-right);
|
|
13
|
-
--dile-floating-menu-background-color: navy;
|
|
14
|
-
--dile-floating-menu-background-color-hover: mediumblue;
|
|
15
|
-
--dile-floating-menu-icon-background-color: aliceblue;
|
|
16
|
-
|
|
15
|
+
display: block;
|
|
17
16
|
position: fixed;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
flex-direction: column;
|
|
21
|
-
align-items: flex-start;
|
|
22
|
-
gap: 1rem;
|
|
23
|
-
top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin));
|
|
24
|
-
left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin));
|
|
17
|
+
top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin, 1.5rem));
|
|
18
|
+
left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin, 1.5rem));
|
|
25
19
|
z-index: var(--dile-floating-menu-zindex, 2);
|
|
26
20
|
}
|
|
21
|
+
:host([hidden]) {
|
|
22
|
+
display: none;
|
|
23
|
+
}
|
|
27
24
|
:host([bottom]) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
top: inherit;
|
|
25
|
+
top: auto;
|
|
26
|
+
bottom: var(--dile-floating-menu-margin-bottom, var(--dile-floating-menu-margin, 1.5rem));
|
|
31
27
|
}
|
|
32
28
|
:host([right]) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
left: inherit;
|
|
29
|
+
left: auto;
|
|
30
|
+
right: var(--dile-floating-menu-margin-right, var(--dile-floating-menu-margin, 1.5rem));
|
|
36
31
|
}
|
|
37
32
|
:host([left]) {
|
|
38
|
-
left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin));
|
|
39
|
-
right:
|
|
33
|
+
left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin, 1.5rem));
|
|
34
|
+
right: auto;
|
|
40
35
|
}
|
|
41
36
|
:host([top]) {
|
|
42
|
-
top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin));
|
|
43
|
-
|
|
44
|
-
bottom: inherit;
|
|
37
|
+
top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin, 1.5rem));
|
|
38
|
+
bottom: auto;
|
|
45
39
|
}
|
|
46
40
|
|
|
47
41
|
:host([opened]) #topline {
|
|
@@ -53,47 +47,72 @@ export class DileFloatingMenu extends LitElement {
|
|
|
53
47
|
}
|
|
54
48
|
|
|
55
49
|
.line {
|
|
56
|
-
background-color: var(--dile-floating-menu-icon-background-color);
|
|
50
|
+
background-color: var(--dile-floating-menu-icon-background-color, aliceblue);
|
|
57
51
|
width: 24px;
|
|
58
52
|
height: 2px;
|
|
59
53
|
transition: transform 0.2s ease-in-out;
|
|
60
54
|
}
|
|
61
55
|
|
|
62
|
-
|
|
56
|
+
#trigger {
|
|
57
|
+
box-sizing: border-box;
|
|
63
58
|
display: flex;
|
|
64
59
|
flex-direction: column;
|
|
65
60
|
justify-content: center;
|
|
66
61
|
align-items: center;
|
|
67
62
|
gap: 4px;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
min-width: var(--dile-floating-menu-button-size, 48px);
|
|
64
|
+
min-height: var(--dile-floating-menu-button-size, 48px);
|
|
65
|
+
padding: var(--dile-floating-menu-button-padding, 0);
|
|
66
|
+
background-color: var(--dile-floating-menu-background-color, navy);
|
|
67
|
+
color: var(--dile-floating-menu-icon-background-color, aliceblue);
|
|
68
|
+
--dile-icon-color: var(--dile-floating-menu-icon-background-color, aliceblue);
|
|
69
|
+
border-radius: var(--dile-floating-menu-button-border-radius, 999px);
|
|
72
70
|
border: none;
|
|
71
|
+
font: inherit;
|
|
73
72
|
cursor: pointer;
|
|
74
|
-
transition: background-color
|
|
73
|
+
transition: background-color 0.3s;
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
background-color: var(--dile-floating-menu-background-color-hover);
|
|
76
|
+
#trigger:hover {
|
|
77
|
+
background-color: var(--dile-floating-menu-background-color-hover, mediumblue);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
#trigger:focus-visible {
|
|
81
|
+
outline: 2px solid var(--dile-floating-menu-background-color, navy);
|
|
82
|
+
outline-offset: 2px;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
#overlay {
|
|
86
|
+
box-sizing: border-box;
|
|
87
|
+
display: none;
|
|
88
|
+
position: fixed;
|
|
89
|
+
width: var(--dile-floating-menu-overlay-width, max-content);
|
|
90
|
+
max-width: var(--dile-floating-menu-overlay-max-width, calc(100vw - 25px));
|
|
91
|
+
max-height: var(--dile-floating-menu-overlay-max-height, calc(100vh - 100px));
|
|
92
|
+
overflow-y: auto;
|
|
93
|
+
background-color: var(--dile-floating-menu-overlay-background-color, var(--dile-floating-menu-background-color, navy));
|
|
94
|
+
color: var(--dile-floating-menu-overlay-color, aliceblue);
|
|
95
|
+
border-radius: var(--dile-floating-menu-overlay-border-radius, 4px);
|
|
96
|
+
box-shadow: var(--dile-floating-menu-overlay-box-shadow, 0 0 20px rgba(0, 0, 0, 0.3));
|
|
97
|
+
padding: var(--dile-floating-menu-overlay-padding, 24px);
|
|
98
|
+
opacity: 0;
|
|
99
|
+
transform: translateY(-10px);
|
|
100
|
+
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
:host([bottom]) #overlay {
|
|
104
|
+
transform: translateY(10px);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:host #overlay.opened {
|
|
108
|
+
opacity: 1;
|
|
109
|
+
transform: translateY(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@media (prefers-reduced-motion: reduce) {
|
|
113
|
+
.line, #trigger, #overlay {
|
|
114
|
+
transition: none;
|
|
115
|
+
}
|
|
97
116
|
}
|
|
98
117
|
`
|
|
99
118
|
];
|
|
@@ -102,28 +121,43 @@ export class DileFloatingMenu extends LitElement {
|
|
|
102
121
|
|
|
103
122
|
opened: {
|
|
104
123
|
type: Boolean,
|
|
105
|
-
reflect: true,
|
|
124
|
+
reflect: true,
|
|
106
125
|
},
|
|
107
126
|
|
|
108
127
|
top: {
|
|
109
128
|
type: Boolean,
|
|
110
|
-
reflect: true,
|
|
129
|
+
reflect: true,
|
|
111
130
|
},
|
|
112
131
|
|
|
113
132
|
bottom: {
|
|
114
133
|
type: Boolean,
|
|
115
|
-
reflect: true,
|
|
134
|
+
reflect: true,
|
|
116
135
|
},
|
|
117
136
|
|
|
118
137
|
right: {
|
|
119
138
|
type: Boolean,
|
|
120
|
-
reflect: true,
|
|
139
|
+
reflect: true,
|
|
121
140
|
},
|
|
122
141
|
|
|
123
142
|
left: {
|
|
124
143
|
type: Boolean,
|
|
125
|
-
reflect: true,
|
|
126
|
-
},
|
|
144
|
+
reflect: true,
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Accessible name of the floating button.
|
|
149
|
+
*/
|
|
150
|
+
label: {
|
|
151
|
+
type: String,
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* If true, the menu closes when any link or button inside it is clicked.
|
|
156
|
+
*/
|
|
157
|
+
closeOnClick: {
|
|
158
|
+
type: Boolean,
|
|
159
|
+
attribute: 'close-on-click',
|
|
160
|
+
},
|
|
127
161
|
}
|
|
128
162
|
|
|
129
163
|
constructor() {
|
|
@@ -133,24 +167,124 @@ export class DileFloatingMenu extends LitElement {
|
|
|
133
167
|
this.left = false;
|
|
134
168
|
this.right = false;
|
|
135
169
|
this.bottom = false;
|
|
170
|
+
this.label = 'Menu';
|
|
171
|
+
this.closeOnClick = false;
|
|
172
|
+
this.documentClickHandler = this._onDocumentClick.bind(this);
|
|
173
|
+
this.addEventListener('focusout', this._onFocusOut);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
connectedCallback() {
|
|
177
|
+
super.connectedCallback();
|
|
178
|
+
document.addEventListener('click', this.documentClickHandler);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
disconnectedCallback() {
|
|
182
|
+
super.disconnectedCallback();
|
|
183
|
+
document.removeEventListener('click', this.documentClickHandler);
|
|
184
|
+
this.opened = false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
updated(changedProperties) {
|
|
188
|
+
if (changedProperties.has('opened')) {
|
|
189
|
+
if (this.opened) {
|
|
190
|
+
this._showOverlay();
|
|
191
|
+
} else if (changedProperties.get('opened')) {
|
|
192
|
+
this._hideOverlay();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
136
195
|
}
|
|
137
196
|
|
|
138
197
|
render() {
|
|
139
198
|
return html`
|
|
140
|
-
<button
|
|
141
|
-
|
|
199
|
+
<button
|
|
200
|
+
id="trigger"
|
|
201
|
+
type="button"
|
|
202
|
+
aria-label="${this.label}"
|
|
203
|
+
aria-expanded="${String(this.opened)}"
|
|
204
|
+
aria-controls="overlay"
|
|
205
|
+
@click="${this.toggle}"
|
|
206
|
+
>
|
|
207
|
+
<slot name="icon">${this._menuIconTpl}</slot>
|
|
142
208
|
</button>
|
|
143
209
|
${this._listTpl}
|
|
144
210
|
`;
|
|
145
211
|
}
|
|
146
212
|
|
|
147
|
-
toggle(
|
|
148
|
-
this.opened
|
|
213
|
+
toggle() {
|
|
214
|
+
if (this.opened) {
|
|
215
|
+
this.close();
|
|
216
|
+
} else {
|
|
217
|
+
this.open();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
open() {
|
|
222
|
+
this.opened = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async close() {
|
|
226
|
+
if (!this.opened) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const hadFocus = this.matches(':focus-within');
|
|
230
|
+
this.opened = false;
|
|
231
|
+
await this.updateComplete;
|
|
232
|
+
if (hadFocus) {
|
|
233
|
+
this.trigger.focus({ preventScroll: true });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_showOverlay() {
|
|
238
|
+
this.verticalAlign = this.bottom ? 'top' : 'bottom';
|
|
239
|
+
this.horizontalAlign = this.right ? 'under_right' : 'under_left';
|
|
240
|
+
// DileOverlay only leaves a gap by itself when the overlay is under the button.
|
|
241
|
+
this.moveTop = this.bottom ? -OVERLAY_GAP : 0;
|
|
242
|
+
super.open();
|
|
243
|
+
}
|
|
244
|
+
|
|
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
|
+
}
|
|
252
|
+
super.close();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
_onDocumentClick(e) {
|
|
256
|
+
if (this.opened && !e.composedPath().includes(this)) {
|
|
257
|
+
this.opened = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_onFocusOut(e) {
|
|
262
|
+
// relatedTarget is null when the focus does not go to another element, like when the window loses it.
|
|
263
|
+
if (this.opened && e.relatedTarget && !this.contains(e.relatedTarget)) {
|
|
264
|
+
this.opened = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_onOverlayClick(e) {
|
|
269
|
+
const path = e.composedPath();
|
|
270
|
+
const nodes = path.slice(0, path.indexOf(e.currentTarget));
|
|
271
|
+
const closer = nodes.find(node =>
|
|
272
|
+
node instanceof Element &&
|
|
273
|
+
(node.hasAttribute('data-close') || (this.closeOnClick && (node.tagName === 'A' || node.tagName === 'BUTTON')))
|
|
274
|
+
);
|
|
275
|
+
if (closer) {
|
|
276
|
+
this.close();
|
|
277
|
+
}
|
|
149
278
|
}
|
|
150
279
|
|
|
151
280
|
get _listTpl() {
|
|
152
281
|
return html`
|
|
153
|
-
<div
|
|
282
|
+
<div
|
|
283
|
+
id="overlay"
|
|
284
|
+
class="${this._overlayClass}"
|
|
285
|
+
?inert="${!this.opened}"
|
|
286
|
+
@click="${this._onOverlayClick}"
|
|
287
|
+
>
|
|
154
288
|
<slot name="content-top"></slot>
|
|
155
289
|
<slot></slot>
|
|
156
290
|
<slot name="content-bottom"></slot>
|
|
@@ -165,4 +299,4 @@ export class DileFloatingMenu extends LitElement {
|
|
|
165
299
|
`
|
|
166
300
|
}
|
|
167
301
|
|
|
168
|
-
}
|
|
302
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
2
3
|
|
|
3
4
|
export class DileFloatingMenuItem extends LitElement {
|
|
4
5
|
static styles = [
|
|
@@ -6,8 +7,9 @@ export class DileFloatingMenuItem extends LitElement {
|
|
|
6
7
|
:host {
|
|
7
8
|
display: block;
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
+
|
|
10
11
|
a {
|
|
12
|
+
display: block;
|
|
11
13
|
text-decoration: none;
|
|
12
14
|
color: var(--dile-floating-menu-item-color, aliceblue);
|
|
13
15
|
margin: var(--dile-floating-menu-item-margin, 0.5rem 0);
|
|
@@ -16,6 +18,11 @@ export class DileFloatingMenuItem extends LitElement {
|
|
|
16
18
|
a:hover {
|
|
17
19
|
color: var(--dile-floating-menu-item-color-hover, lightblue);
|
|
18
20
|
}
|
|
21
|
+
|
|
22
|
+
a:focus-visible {
|
|
23
|
+
outline: 2px solid currentColor;
|
|
24
|
+
outline-offset: 2px;
|
|
25
|
+
}
|
|
19
26
|
`
|
|
20
27
|
];
|
|
21
28
|
|
|
@@ -23,7 +30,7 @@ export class DileFloatingMenuItem extends LitElement {
|
|
|
23
30
|
|
|
24
31
|
href: {
|
|
25
32
|
type: String,
|
|
26
|
-
reflect: true,
|
|
33
|
+
reflect: true,
|
|
27
34
|
},
|
|
28
35
|
|
|
29
36
|
target: {
|
|
@@ -36,7 +43,7 @@ export class DileFloatingMenuItem extends LitElement {
|
|
|
36
43
|
super();
|
|
37
44
|
this.href = '';
|
|
38
45
|
this.target = '';
|
|
39
|
-
|
|
46
|
+
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
connectedCallback() {
|
|
@@ -46,7 +53,7 @@ export class DileFloatingMenuItem extends LitElement {
|
|
|
46
53
|
|
|
47
54
|
render() {
|
|
48
55
|
return html`
|
|
49
|
-
<a href="${this.href}" target="${this.target}"
|
|
56
|
+
<a href="${ifDefined(this.href || undefined)}" target="${ifDefined(this.target || undefined)}"><slot></slot></a>
|
|
50
57
|
`;
|
|
51
58
|
}
|
|
52
|
-
}
|
|
59
|
+
}
|
|
@@ -46,6 +46,28 @@ export class DileMenuOverlay extends DileOverlay(DileCloseDocumentClick(LitEleme
|
|
|
46
46
|
constructor() {
|
|
47
47
|
super();
|
|
48
48
|
this.closeOnClickInside = false;
|
|
49
|
+
this._escKeydownHandler = this._onEscKeydown.bind(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
connectedCallback() {
|
|
53
|
+
super.connectedCallback();
|
|
54
|
+
// In the capture phase, so an opened menu gets the key before the layers that contain it, like a modal.
|
|
55
|
+
window.addEventListener('keydown', this._escKeydownHandler, true);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
disconnectedCallback() {
|
|
59
|
+
super.disconnectedCallback();
|
|
60
|
+
window.removeEventListener('keydown', this._escKeydownHandler, true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
updated(changedProperties) {
|
|
64
|
+
super.updated(changedProperties);
|
|
65
|
+
if (changedProperties.has('_overlayClass') && this._overlayClass === 'opened') {
|
|
66
|
+
this.dispatchEvent(new CustomEvent('overlay-ready', {
|
|
67
|
+
bubbles: true,
|
|
68
|
+
composed: true,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
49
71
|
}
|
|
50
72
|
|
|
51
73
|
render() {
|
|
@@ -56,6 +78,7 @@ export class DileMenuOverlay extends DileOverlay(DileCloseDocumentClick(LitEleme
|
|
|
56
78
|
<div
|
|
57
79
|
id="overlay"
|
|
58
80
|
class="${this._overlayClass}"
|
|
81
|
+
?inert="${!this._overlayClass && !this._opening}"
|
|
59
82
|
@click="${this.clickHandler}"
|
|
60
83
|
>
|
|
61
84
|
<slot name="content"></slot>
|
|
@@ -63,6 +86,33 @@ export class DileMenuOverlay extends DileOverlay(DileCloseDocumentClick(LitEleme
|
|
|
63
86
|
`;
|
|
64
87
|
}
|
|
65
88
|
|
|
89
|
+
open() {
|
|
90
|
+
if (!this._overlayClass && !this._opening) {
|
|
91
|
+
this._focusBeforeOpen = this._getActiveElement();
|
|
92
|
+
}
|
|
93
|
+
super.open();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_getActiveElement() {
|
|
97
|
+
let active = document.activeElement;
|
|
98
|
+
while (active?.shadowRoot?.activeElement) {
|
|
99
|
+
active = active.shadowRoot.activeElement;
|
|
100
|
+
}
|
|
101
|
+
return active;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_onEscKeydown(e) {
|
|
105
|
+
if (e.key !== 'Escape' || this._overlayClass !== 'opened') {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Only the menu closes: a modal or drawer that contains it stays opened until the next Escape.
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
this.close();
|
|
111
|
+
if (this.overlay.matches(':focus-within') && this._focusBeforeOpen?.isConnected) {
|
|
112
|
+
this._focusBeforeOpen.focus();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
66
116
|
clickHandler(e) {
|
|
67
117
|
if(! this.closeOnClickInside) {
|
|
68
118
|
e.stopPropagation();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html, css, LitElement } from "lit";
|
|
2
|
+
import { ifDefined } from "lit/directives/if-defined.js";
|
|
2
3
|
import { DileSelectableItem } from "../../../mixins/selectable/index.js";
|
|
3
4
|
import '@dile/iconlib/dile-iconlib.js';
|
|
4
5
|
|
|
@@ -21,7 +22,19 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
|
|
|
21
22
|
display: block;
|
|
22
23
|
cursor: pointer;
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
.item {
|
|
26
|
+
appearance: none;
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
width: 100%;
|
|
29
|
+
margin: 0;
|
|
30
|
+
border: 0;
|
|
31
|
+
border-radius: 0;
|
|
32
|
+
font: inherit;
|
|
33
|
+
letter-spacing: inherit;
|
|
34
|
+
word-spacing: inherit;
|
|
35
|
+
text-align: inherit;
|
|
36
|
+
text-transform: inherit;
|
|
37
|
+
cursor: inherit;
|
|
25
38
|
display: flex;
|
|
26
39
|
align-items: center;
|
|
27
40
|
padding-top: var(--dile-selector-padding-y, 0.2rem);
|
|
@@ -32,6 +45,11 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
|
|
|
32
45
|
color: var(--dile-selector-text-color, var(--dile-on-background-color, #303030));
|
|
33
46
|
transition: all 0.3s ease-in;
|
|
34
47
|
}
|
|
48
|
+
.item:focus-visible {
|
|
49
|
+
outline: var(--dile-selector-focus-ring-width, 2px) solid var(--dile-selector-focus-ring-color, currentColor);
|
|
50
|
+
outline-offset: var(--dile-selector-focus-ring-offset, -2px);
|
|
51
|
+
transition-property: background-color, color;
|
|
52
|
+
}
|
|
35
53
|
.selected {
|
|
36
54
|
background-color: var(--dile-selector-selected-background-color, #039be5);
|
|
37
55
|
color: var(--dile-selector-selected-text-color, #fff);
|
|
@@ -59,8 +77,7 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
|
|
|
59
77
|
.selected .icon dile-iconlib {
|
|
60
78
|
--dile-icon-color: var(--dile-selector-selected-icon-color, #fff);
|
|
61
79
|
}
|
|
62
|
-
a {
|
|
63
|
-
display: block;
|
|
80
|
+
a.item {
|
|
64
81
|
text-decoration: var(--dile-selector-text-decoration, none);
|
|
65
82
|
}
|
|
66
83
|
`;
|
|
@@ -71,6 +88,8 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
|
|
|
71
88
|
icon: { type: String },
|
|
72
89
|
iconlibIcon: { type: String },
|
|
73
90
|
href: { type: String },
|
|
91
|
+
/** ARIA role of the inner control. dile-selector-overlay sets it to "menuitem" */
|
|
92
|
+
itemRole: { type: String },
|
|
74
93
|
};
|
|
75
94
|
}
|
|
76
95
|
|
|
@@ -80,29 +99,55 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
|
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
render() {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
const itemClass = `item ${this.selected ? 'selected' : ''}`;
|
|
103
|
+
const ariaCurrent = ifDefined(this.selected ? 'true' : undefined);
|
|
104
|
+
const role = ifDefined(this.itemRole || undefined);
|
|
105
|
+
return this.href == ''
|
|
106
|
+
? html`<button
|
|
107
|
+
type="button"
|
|
108
|
+
class="${itemClass}"
|
|
109
|
+
role="${role}"
|
|
110
|
+
aria-current="${ariaCurrent}"
|
|
111
|
+
@click="${this.select}"
|
|
112
|
+
>${this.itemTemplate}</button>`
|
|
113
|
+
: html`<a
|
|
114
|
+
href="${this.href}"
|
|
115
|
+
class="${itemClass}"
|
|
116
|
+
role="${role}"
|
|
117
|
+
aria-current="${ariaCurrent}"
|
|
118
|
+
@click="${this.select}"
|
|
119
|
+
@keydown="${this._onLinkKeydown}"
|
|
120
|
+
>${this.itemTemplate}</a>`;
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
get itemTemplate() {
|
|
92
124
|
return html`
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<div class="line"><slot></slot></div>
|
|
103
|
-
</article>
|
|
125
|
+
${this.icon
|
|
126
|
+
? html`<span class="icon">${this.iconElement(this.icon)}</span>`
|
|
127
|
+
: ''
|
|
128
|
+
}
|
|
129
|
+
${this.iconlibIcon
|
|
130
|
+
? html`<span class="icon">${this.renderIconlibIcon(this.iconlibIcon)}</span>`
|
|
131
|
+
: ''
|
|
132
|
+
}
|
|
133
|
+
<span class="line"><slot></slot></span>
|
|
104
134
|
`;
|
|
105
135
|
}
|
|
136
|
+
|
|
137
|
+
// A native link is only activated with Enter
|
|
138
|
+
_onLinkKeydown(e) {
|
|
139
|
+
if (e.key === ' ') {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
if (!e.repeat) {
|
|
142
|
+
e.currentTarget.click();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
focus(options) {
|
|
148
|
+
this.shadowRoot?.querySelector('.item')?.focus(options);
|
|
149
|
+
}
|
|
150
|
+
|
|
106
151
|
iconElement(icon) {
|
|
107
152
|
return icons[icon];
|
|
108
153
|
}
|
|
@@ -21,7 +21,11 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
|
21
21
|
|
|
22
22
|
_displayLabel: { state: true },
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
_menuOpened: { state: true },
|
|
25
|
+
|
|
26
|
+
/** Accessible text saying that the trigger opens a menu of options. It's the whole accessible name
|
|
27
|
+
* when there is no visible label text, and it follows the label text when there is one.
|
|
28
|
+
* Set it to translate it, or to an empty string to expose only the label text. */
|
|
25
29
|
triggerAriaLabel: { type: String },
|
|
26
30
|
|
|
27
31
|
/** Forwarded to the internal dile-menu-overlay */
|
|
@@ -56,6 +60,7 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
|
56
60
|
this.dynamicLabel = false;
|
|
57
61
|
this.icon = 'ellipsis-vertical';
|
|
58
62
|
this._displayLabel = null;
|
|
63
|
+
this._menuOpened = false;
|
|
59
64
|
this.triggerAriaLabel = 'Options';
|
|
60
65
|
this.horizontalAlign = 'under_left';
|
|
61
66
|
this.verticalAlign = 'bottom';
|
|
@@ -71,6 +76,9 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
|
71
76
|
moveTop="${this.moveTop}"
|
|
72
77
|
moveLeft="${this.moveLeft}"
|
|
73
78
|
closeOnClickInside
|
|
79
|
+
@overlay-opened="${this._onOverlayOpened}"
|
|
80
|
+
@overlay-ready="${this._onOverlayReady}"
|
|
81
|
+
@overlay-closed="${this._onOverlayClosed}"
|
|
74
82
|
>
|
|
75
83
|
<span
|
|
76
84
|
slot="trigger"
|
|
@@ -78,7 +86,8 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
|
78
86
|
role="button"
|
|
79
87
|
tabindex="0"
|
|
80
88
|
aria-haspopup="true"
|
|
81
|
-
aria-
|
|
89
|
+
aria-expanded="${this._menuOpened ? 'true' : 'false'}"
|
|
90
|
+
aria-label="${this._triggerAccessibleName}"
|
|
82
91
|
@keydown="${this._onTriggerKeydown}"
|
|
83
92
|
>
|
|
84
93
|
${this._displayLabel
|
|
@@ -86,8 +95,8 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
|
86
95
|
: html`<dile-lucide-icon icon="${this.icon}" rounded></dile-lucide-icon>`
|
|
87
96
|
}
|
|
88
97
|
</span>
|
|
89
|
-
<div slot="content">
|
|
90
|
-
<slot></slot>
|
|
98
|
+
<div slot="content" role="menu" aria-labelledby="trigger" @keydown="${this._onMenuKeydown}">
|
|
99
|
+
<slot @slotchange="${this._onSlotChange}"></slot>
|
|
91
100
|
</div>
|
|
92
101
|
</dile-menu-overlay>
|
|
93
102
|
`;
|
|
@@ -124,10 +133,88 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
|
124
133
|
this._displayLabel = selectedItem ? selectedItem.textContent.trim() : this.label;
|
|
125
134
|
}
|
|
126
135
|
|
|
136
|
+
get _triggerAccessibleName() {
|
|
137
|
+
if (!this._displayLabel) {
|
|
138
|
+
return this.triggerAriaLabel;
|
|
139
|
+
}
|
|
140
|
+
return !this.triggerAriaLabel || this.triggerAriaLabel === this._displayLabel
|
|
141
|
+
? this._displayLabel
|
|
142
|
+
: `${this._displayLabel}, ${this.triggerAriaLabel}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get _menu() {
|
|
146
|
+
return this.shadowRoot.querySelector('dile-menu-overlay');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Children that aren't options (e.g. a separator) and hidden options are skipped.
|
|
150
|
+
get _focusableItems() {
|
|
151
|
+
return this.getItems().filter((item) => 'selected' in item && item.getClientRects().length > 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_onSlotChange() {
|
|
155
|
+
this.getItems().forEach((item) => {
|
|
156
|
+
item.itemRole = 'menuitem';
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_onOverlayOpened(e) {
|
|
161
|
+
if (e.target === e.currentTarget) {
|
|
162
|
+
this._menuOpened = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_onOverlayReady(e) {
|
|
167
|
+
if (e.target === e.currentTarget) {
|
|
168
|
+
const items = this._focusableItems;
|
|
169
|
+
(items.find((item) => item.selected) ?? items[0])?.focus();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_onOverlayClosed(e) {
|
|
174
|
+
if (e.target !== e.currentTarget) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this._menuOpened = false;
|
|
178
|
+
// When the menu closes by clicking elsewhere, the focus is already out of here and has to stay there.
|
|
179
|
+
if (this.matches(':focus-within')) {
|
|
180
|
+
this.shadowRoot.getElementById('trigger').focus();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
127
184
|
_onTriggerKeydown(e) {
|
|
128
185
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
129
186
|
e.preventDefault();
|
|
130
|
-
e.
|
|
187
|
+
if (!e.repeat) {
|
|
188
|
+
e.currentTarget.click();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_onMenuKeydown(e) {
|
|
194
|
+
if (e.key === 'Tab') {
|
|
195
|
+
// The focus goes back to the trigger, so the browser moves it from there to the next element of the page.
|
|
196
|
+
this._menu.close();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (e.altKey || e.ctrlKey || e.metaKey) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if ((e.key === 'Enter' || e.key === ' ') && e.repeat) {
|
|
203
|
+
// Keeping the key pressed on the trigger must not activate the option that received the focus.
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const items = this._focusableItems;
|
|
208
|
+
const current = items.findIndex((item) => item.matches(':focus-within'));
|
|
209
|
+
const nextIndex = {
|
|
210
|
+
ArrowDown: (current + 1) % items.length,
|
|
211
|
+
ArrowUp: (current - 1 + items.length) % items.length,
|
|
212
|
+
Home: 0,
|
|
213
|
+
End: items.length - 1,
|
|
214
|
+
}[e.key];
|
|
215
|
+
if (items[nextIndex]) {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
items[nextIndex].focus();
|
|
131
218
|
}
|
|
132
219
|
}
|
|
133
220
|
}
|
|
@@ -8,16 +8,36 @@ export class DileTab extends DileSelectableItem(LitElement) {
|
|
|
8
8
|
return {
|
|
9
9
|
href: { type: String },
|
|
10
10
|
target: { type: String },
|
|
11
|
+
/** If this tab is in the page tab sequence. dile-tabs keeps it true only on one of its tabs */
|
|
12
|
+
tabbable: { type: Boolean, attribute: false },
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
this.tabbable = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
static get styles() {
|
|
15
22
|
return css`
|
|
16
23
|
:host {
|
|
17
24
|
display: inline-block;
|
|
18
25
|
margin: 0 3px;
|
|
19
26
|
}
|
|
20
|
-
|
|
27
|
+
.tab {
|
|
28
|
+
appearance: none;
|
|
29
|
+
display: block;
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
width: 100%;
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 0;
|
|
34
|
+
border-radius: 0;
|
|
35
|
+
font: inherit;
|
|
36
|
+
letter-spacing: inherit;
|
|
37
|
+
word-spacing: inherit;
|
|
38
|
+
text-align: inherit;
|
|
39
|
+
text-transform: inherit;
|
|
40
|
+
text-decoration: none;
|
|
21
41
|
border-top-left-radius: var(--dile-tab-border-radius, 4px);
|
|
22
42
|
border-top-right-radius: var(--dile-tab-border-radius, 4px);
|
|
23
43
|
transition: all 0.3s ease;
|
|
@@ -28,7 +48,13 @@ export class DileTab extends DileSelectableItem(LitElement) {
|
|
|
28
48
|
font-weight: var(--dile-tab-font-weight, normal);
|
|
29
49
|
font-size: var(--dile-tab-font-size, 1rem);
|
|
30
50
|
}
|
|
31
|
-
|
|
51
|
+
.tab:focus-visible {
|
|
52
|
+
outline: var(--dile-tab-focus-ring-width, 2px) solid var(--dile-tab-focus-ring-color, currentColor);
|
|
53
|
+
outline-offset: var(--dile-tab-focus-ring-offset, -2px);
|
|
54
|
+
transition-property: background-color, color;
|
|
55
|
+
}
|
|
56
|
+
.label {
|
|
57
|
+
display: block;
|
|
32
58
|
padding: var(--dile-tab-padding, 8px 12px 6px 12px);
|
|
33
59
|
text-transform: var(--dile-tab-text-transform, uppercase);
|
|
34
60
|
white-space: nowrap;
|
|
@@ -38,7 +64,7 @@ export class DileTab extends DileSelectableItem(LitElement) {
|
|
|
38
64
|
color: var(--dile-tab-selected-text-color, var(--dile-on-primary-dark-color, #000));
|
|
39
65
|
border: var(--dile-tab-selected-border, none);
|
|
40
66
|
}
|
|
41
|
-
|
|
67
|
+
.mark {
|
|
42
68
|
display: block;
|
|
43
69
|
height: var(--dile-tab-selected-line-height, 5px);
|
|
44
70
|
width: 0;
|
|
@@ -48,11 +74,6 @@ export class DileTab extends DileSelectableItem(LitElement) {
|
|
|
48
74
|
.markselected {
|
|
49
75
|
width: 100%;
|
|
50
76
|
}
|
|
51
|
-
a {
|
|
52
|
-
display: block;
|
|
53
|
-
color: inherit;
|
|
54
|
-
text-decoration: none;
|
|
55
|
-
}
|
|
56
77
|
.line {
|
|
57
78
|
display: flex;
|
|
58
79
|
justify-content: center;
|
|
@@ -61,22 +82,55 @@ export class DileTab extends DileSelectableItem(LitElement) {
|
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
render() {
|
|
85
|
+
const tabClass = `tab ${this.selected ? 'selected' : ''}`;
|
|
86
|
+
const ariaSelected = this.selected ? 'true' : 'false';
|
|
87
|
+
const tabindex = this.tabbable ? '0' : '-1';
|
|
64
88
|
if (this.href) {
|
|
65
89
|
return html`
|
|
66
|
-
<a
|
|
90
|
+
<a
|
|
91
|
+
href="${this.href}"
|
|
92
|
+
target="${ifDefined(this.target)}"
|
|
93
|
+
class="${tabClass}"
|
|
94
|
+
role="tab"
|
|
95
|
+
aria-selected="${ariaSelected}"
|
|
96
|
+
tabindex="${tabindex}"
|
|
97
|
+
@click="${this.select}"
|
|
98
|
+
@keydown="${this._onLinkKeydown}"
|
|
99
|
+
>${this.tabTemplate}</a>
|
|
67
100
|
`;
|
|
68
101
|
}
|
|
69
|
-
return
|
|
102
|
+
return html`
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
class="${tabClass}"
|
|
106
|
+
role="tab"
|
|
107
|
+
aria-selected="${ariaSelected}"
|
|
108
|
+
tabindex="${tabindex}"
|
|
109
|
+
@click="${this.select}"
|
|
110
|
+
>${this.tabTemplate}</button>
|
|
111
|
+
`;
|
|
70
112
|
}
|
|
71
113
|
|
|
72
114
|
get tabTemplate() {
|
|
73
115
|
return html`
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
</div>
|
|
79
|
-
</article>
|
|
116
|
+
<span class="label"><slot></slot></span>
|
|
117
|
+
<span class="line">
|
|
118
|
+
<span class="mark ${this.selected ? 'markselected' : ''}"></span>
|
|
119
|
+
</span>
|
|
80
120
|
`;
|
|
81
121
|
}
|
|
122
|
+
|
|
123
|
+
// A native link is only activated with Enter
|
|
124
|
+
_onLinkKeydown(e) {
|
|
125
|
+
if (e.key === ' ') {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
if (!e.repeat) {
|
|
128
|
+
e.currentTarget.click();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
focus(options) {
|
|
134
|
+
this.shadowRoot?.querySelector('.tab')?.focus(options);
|
|
135
|
+
}
|
|
82
136
|
}
|
|
@@ -9,4 +9,67 @@ export class DileTabs extends DileSelectable(LitElement) {
|
|
|
9
9
|
}
|
|
10
10
|
`;
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
super();
|
|
15
|
+
this.addEventListener('keydown', this._onKeydown);
|
|
16
|
+
this.addEventListener('focusin', () => this._updateTabbable());
|
|
17
|
+
this.addEventListener('focusout', this._onFocusOut);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
connectedCallback() {
|
|
21
|
+
super.connectedCallback();
|
|
22
|
+
if (!this.hasAttribute('role')) {
|
|
23
|
+
this.setAttribute('role', 'tablist');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
render() {
|
|
28
|
+
return html`<slot @slotchange="${() => this._updateTabbable()}"></slot>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setSelectedItem() {
|
|
32
|
+
super.setSelectedItem();
|
|
33
|
+
this._updateTabbable();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Roving tabindex: the tablist is only one stop in the page tab sequence.
|
|
37
|
+
_updateTabbable(ignoreFocus = false) {
|
|
38
|
+
const tabs = this.getItems();
|
|
39
|
+
const focused = ignoreFocus ? undefined : tabs.find((tab) => tab.matches(':focus-within'));
|
|
40
|
+
const tabbable = focused ?? tabs.find((tab) => tab.selected) ?? tabs[0];
|
|
41
|
+
tabs.forEach((tab) => {
|
|
42
|
+
tab.tabbable = tab === tabbable;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_onFocusOut(e) {
|
|
47
|
+
if (!e.relatedTarget || !this.contains(e.relatedTarget)) {
|
|
48
|
+
this._updateTabbable(true);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The arrows only move the focus. Enter or Space select the focused tab, so moving
|
|
53
|
+
// through the tabs doesn't dispatch changes or follow the links of the tabs with href.
|
|
54
|
+
_onKeydown(e) {
|
|
55
|
+
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (e.composedPath()[0].getAttribute?.('role') !== 'tab') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const tabs = this.getItems();
|
|
62
|
+
const current = tabs.findIndex((tab) => tab.matches(':focus-within'));
|
|
63
|
+
const step = getComputedStyle(this).direction === 'rtl' ? -1 : 1;
|
|
64
|
+
const nextIndex = {
|
|
65
|
+
ArrowRight: (current + step + tabs.length) % tabs.length,
|
|
66
|
+
ArrowLeft: (current - step + tabs.length) % tabs.length,
|
|
67
|
+
Home: 0,
|
|
68
|
+
End: tabs.length - 1,
|
|
69
|
+
}[e.key];
|
|
70
|
+
if (tabs[nextIndex]) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
tabs[nextIndex].focus();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
12
75
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dile/ui",
|
|
3
|
-
"version": "3.18.
|
|
3
|
+
"version": "3.18.3",
|
|
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": "
|
|
31
|
+
"gitHead": "978a1d5b553cf39a5614223130f3446ec0bb89f2"
|
|
32
32
|
}
|