@dile/ui 3.15.0 → 3.16.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.
- package/components/drop-file/drop-file.spec.js +102 -0
- package/components/drop-file/src/DileDropFile.js +84 -7
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-closes-only-the-topmost-modal-when-Esc-is-pressed-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-dispatches-dile-modal-closed-and-animates-when-the-opened-property-is-set-to-false-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-does-not-close-a-blocking-modal-when-Esc-is-pressed-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-does-not-close-the-modal-below-when-the-topmost-modal-is-blocking-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-ignores-transitionend-events-coming-from-inner-elements-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-keeps-the-focus-on-content-focused-by-the-user-when-opening-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-opens-and-closes-through-the-opened-attribute-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-reflects-the-opened-property-to-the-attribute-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-releases-the-background-layer-after-the-close-animation-even-without-transitionend-1.png +0 -0
- package/components/modal/__screenshots__/modal.spec.js/dile-modal-releases-the-background-layer-immediately-when-the-animation-duration-is-0s-1.png +0 -0
- package/components/modal/modal.spec.js +204 -0
- package/components/modal/src/DileModal.js +242 -27
- package/package.json +2 -2
|
@@ -109,4 +109,106 @@ describe('dile-drop-file', () => {
|
|
|
109
109
|
expect(eventDetail).toBeTruthy();
|
|
110
110
|
expect(eventDetail.file).toBeNull();
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
describe('multiple mode', () => {
|
|
114
|
+
function createFile(name, content = 'content') {
|
|
115
|
+
return new File([content], name, { lastModified: 1 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function dropFiles(el, files) {
|
|
119
|
+
const dataTransfer = new DataTransfer();
|
|
120
|
+
files.forEach(file => dataTransfer.items.add(file));
|
|
121
|
+
const dropZone = el.shadowRoot.querySelector('#dropZone');
|
|
122
|
+
dropZone.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true, cancelable: true }));
|
|
123
|
+
return el.updateComplete;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('renders the file input with the multiple attribute', async () => {
|
|
127
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
128
|
+
expect(el.shadowRoot.querySelector('input[type="file"]').hasAttribute('multiple')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not render the multiple attribute by default', async () => {
|
|
132
|
+
const el = await renderDropFile('<dile-drop-file></dile-drop-file>');
|
|
133
|
+
expect(el.shadowRoot.querySelector('input[type="file"]').hasAttribute('multiple')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('accepts several dropped files and renders a preview for each one', async () => {
|
|
137
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
138
|
+
let eventDetail = null;
|
|
139
|
+
el.addEventListener('dile-drop-file-changed', (e) => { eventDetail = e.detail; });
|
|
140
|
+
|
|
141
|
+
await dropFiles(el, [createFile('a.txt'), createFile('b.txt')]);
|
|
142
|
+
|
|
143
|
+
expect(el.shadowRoot.querySelectorAll('dile-file-preview').length).toBe(2);
|
|
144
|
+
expect(el.value.map(file => file.name)).toEqual(['a.txt', 'b.txt']);
|
|
145
|
+
expect(el.getFiles().length).toBe(2);
|
|
146
|
+
expect(el.shadowRoot.querySelector('#fileInput').files.length).toBe(2);
|
|
147
|
+
expect(eventDetail.files.map(file => file.name)).toEqual(['a.txt', 'b.txt']);
|
|
148
|
+
expect(eventDetail.file.name).toBe('a.txt');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('accumulates files between selections without duplicates', async () => {
|
|
152
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
153
|
+
await dropFiles(el, [createFile('a.txt')]);
|
|
154
|
+
await dropFiles(el, [createFile('a.txt'), createFile('b.txt')]);
|
|
155
|
+
|
|
156
|
+
expect(el.value.map(file => file.name)).toEqual(['a.txt', 'b.txt']);
|
|
157
|
+
expect(el.shadowRoot.querySelectorAll('dile-file-preview').length).toBe(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('removes a single file with removeFile', async () => {
|
|
161
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
162
|
+
await dropFiles(el, [createFile('a.txt'), createFile('b.txt'), createFile('c.txt')]);
|
|
163
|
+
|
|
164
|
+
el.removeFile(1);
|
|
165
|
+
await el.updateComplete;
|
|
166
|
+
|
|
167
|
+
expect(el.value.map(file => file.name)).toEqual(['a.txt', 'c.txt']);
|
|
168
|
+
expect(el.shadowRoot.querySelector('#fileInput').files.length).toBe(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('removes a file when its preview clear link is clicked', async () => {
|
|
172
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
173
|
+
await dropFiles(el, [createFile('a.txt'), createFile('b.txt')]);
|
|
174
|
+
|
|
175
|
+
const preview = el.shadowRoot.querySelectorAll('dile-file-preview')[0];
|
|
176
|
+
await preview.updateComplete;
|
|
177
|
+
preview.shadowRoot.querySelector('a').click();
|
|
178
|
+
await el.updateComplete;
|
|
179
|
+
|
|
180
|
+
expect(el.value.map(file => file.name)).toEqual(['b.txt']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('discards files with invalid extensions and keeps the valid ones', async () => {
|
|
184
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
185
|
+
el.allowedExtensions = ['pdf'];
|
|
186
|
+
await dropFiles(el, [createFile('a.pdf'), createFile('b.exe')]);
|
|
187
|
+
|
|
188
|
+
expect(el.value.map(file => file.name)).toEqual(['a.pdf']);
|
|
189
|
+
expect(el.errored).toBe(true);
|
|
190
|
+
expect(el.shadowRoot.querySelector('.message.errored-msg')).toBeTruthy();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('clears all the files', async () => {
|
|
194
|
+
const el = await renderDropFile('<dile-drop-file multiple></dile-drop-file>');
|
|
195
|
+
await dropFiles(el, [createFile('a.txt'), createFile('b.txt')]);
|
|
196
|
+
|
|
197
|
+
el.clear();
|
|
198
|
+
await el.updateComplete;
|
|
199
|
+
|
|
200
|
+
expect(el.value).toEqual([]);
|
|
201
|
+
expect(el.shadowRoot.querySelectorAll('dile-file-preview').length).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('sets all the files as form value', async () => {
|
|
205
|
+
document.body.innerHTML = '<form><dile-drop-file name="docs" multiple></dile-drop-file></form>';
|
|
206
|
+
const el = document.querySelector('dile-drop-file');
|
|
207
|
+
await el.updateComplete;
|
|
208
|
+
await dropFiles(el, [createFile('a.txt'), createFile('b.txt')]);
|
|
209
|
+
|
|
210
|
+
const formData = new FormData(document.querySelector('form'));
|
|
211
|
+
expect(formData.getAll('docs').map(file => file.name)).toEqual(['a.txt', 'b.txt']);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
112
214
|
});
|
|
@@ -64,6 +64,8 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
64
64
|
name: { type: String, reflect: true },
|
|
65
65
|
accept: { type: String },
|
|
66
66
|
capture: { type: String },
|
|
67
|
+
multiple: { type: Boolean, reflect: true },
|
|
68
|
+
files: { state: true },
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -83,6 +85,8 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
83
85
|
this.allowedExtensions = [];
|
|
84
86
|
this.accept = '';
|
|
85
87
|
this.capture = '';
|
|
88
|
+
this.multiple = false;
|
|
89
|
+
this.files = [];
|
|
86
90
|
this.internals = this.attachInternals();
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -109,12 +113,23 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
109
113
|
${this.dropLabelTemplate}
|
|
110
114
|
</div>
|
|
111
115
|
${this.messageTemplate}
|
|
112
|
-
<input type="file" id="fileInput" @change=${this._handleFileInput} accept=${ifDefined(this.accept || undefined)} capture=${ifDefined(this.capture || undefined)}>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
<input type="file" id="fileInput" ?multiple=${this.multiple} @change=${this._handleFileInput} accept=${ifDefined(this.accept || undefined)} capture=${ifDefined(this.capture || undefined)}>
|
|
117
|
+
${this.multiple
|
|
118
|
+
? this.files.map((file, index) => html`
|
|
119
|
+
<dile-file-preview
|
|
120
|
+
fileName="${file.name}"
|
|
121
|
+
selectedFileLabel="${this.selectedFileLabel}"
|
|
122
|
+
@dile-file-clear=${() => this.removeFile(index)}
|
|
123
|
+
></dile-file-preview>
|
|
124
|
+
`)
|
|
125
|
+
: html`
|
|
126
|
+
<dile-file-preview
|
|
127
|
+
fileName="${this.fileName}"
|
|
128
|
+
selectedFileLabel="${this.selectedFileLabel}"
|
|
129
|
+
@dile-file-clear=${this.clear}
|
|
130
|
+
></dile-file-preview>
|
|
131
|
+
`
|
|
132
|
+
}
|
|
118
133
|
`;
|
|
119
134
|
}
|
|
120
135
|
|
|
@@ -167,6 +182,10 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
_processFile(files) {
|
|
185
|
+
if (this.multiple) {
|
|
186
|
+
this._processMultipleFiles(files);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
170
189
|
this.internals.setFormValue(files.length > 0 ? files[0] : null);
|
|
171
190
|
if (files.length > 0) {
|
|
172
191
|
this.fileName = files[0].name;
|
|
@@ -183,6 +202,56 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
183
202
|
}
|
|
184
203
|
}
|
|
185
204
|
|
|
205
|
+
_processMultipleFiles(files) {
|
|
206
|
+
const incoming = Array.from(files);
|
|
207
|
+
if (incoming.length === 0) {
|
|
208
|
+
// Restore the input files, as a canceled dialog empties it
|
|
209
|
+
this._syncFileInput();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const validFiles = incoming.filter(file => this._isValidExtension(file.name));
|
|
213
|
+
const newFiles = validFiles.filter(file => !this.files.some(current => this._isSameFile(current, file)));
|
|
214
|
+
this.files = [...this.files, ...newFiles];
|
|
215
|
+
if (validFiles.length < incoming.length) {
|
|
216
|
+
this.message = `${this.extensionErrorMessage} ${this.allowedExtensions.join(', ')}`;
|
|
217
|
+
this.errored = true;
|
|
218
|
+
} else {
|
|
219
|
+
this.message = this.firstMessage || '';
|
|
220
|
+
this.errored = false;
|
|
221
|
+
}
|
|
222
|
+
this._syncFiles();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_isSameFile(a, b) {
|
|
226
|
+
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_syncFileInput() {
|
|
230
|
+
if (!this.fileInput) return;
|
|
231
|
+
const dataTransfer = new DataTransfer();
|
|
232
|
+
this.files.forEach(file => dataTransfer.items.add(file));
|
|
233
|
+
this.fileInput.files = dataTransfer.files;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_syncFiles() {
|
|
237
|
+
this._syncFileInput();
|
|
238
|
+
if (this.files.length > 0) {
|
|
239
|
+
const formData = new FormData();
|
|
240
|
+
this.files.forEach(file => formData.append(this.name || 'files', file));
|
|
241
|
+
this.internals.setFormValue(formData);
|
|
242
|
+
} else {
|
|
243
|
+
this.internals.setFormValue(null);
|
|
244
|
+
}
|
|
245
|
+
this.emmitChange();
|
|
246
|
+
this._emitDropFileChanged(this.files[0] || null);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
removeFile(index) {
|
|
250
|
+
if (index < 0 || index >= this.files.length) return;
|
|
251
|
+
this.files = this.files.filter((_, i) => i !== index);
|
|
252
|
+
this._syncFiles();
|
|
253
|
+
}
|
|
254
|
+
|
|
186
255
|
_isValidExtension(fileName) {
|
|
187
256
|
if(this.allowedExtensions.length === 0) {
|
|
188
257
|
return true;
|
|
@@ -216,7 +285,8 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
216
285
|
bubbles: true,
|
|
217
286
|
composed: true,
|
|
218
287
|
detail: {
|
|
219
|
-
file: file
|
|
288
|
+
file: file,
|
|
289
|
+
files: this.multiple ? [...this.files] : (file ? [file] : []),
|
|
220
290
|
}
|
|
221
291
|
}));
|
|
222
292
|
}
|
|
@@ -228,16 +298,23 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
|
|
|
228
298
|
clear() {
|
|
229
299
|
this.fileInput.value = "";
|
|
230
300
|
this.fileName = "";
|
|
301
|
+
this.files = [];
|
|
231
302
|
this.internals.setFormValue(null);
|
|
232
303
|
this._emitDropFileChanged(null);
|
|
233
304
|
this.emmitChange();
|
|
234
305
|
}
|
|
235
306
|
|
|
236
307
|
getFiles() {
|
|
308
|
+
if (this.multiple) {
|
|
309
|
+
return [...this.files];
|
|
310
|
+
}
|
|
237
311
|
return this.fileInput.files;
|
|
238
312
|
}
|
|
239
313
|
|
|
240
314
|
get value() {
|
|
315
|
+
if (this.multiple) {
|
|
316
|
+
return [...this.files];
|
|
317
|
+
}
|
|
241
318
|
if(this.fileInput) {
|
|
242
319
|
return this.fileInput.files[0];
|
|
243
320
|
} else {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -43,4 +43,208 @@ describe('dile-modal', () => {
|
|
|
43
43
|
expect(el.opened).toBe(false);
|
|
44
44
|
expect(closed).toBe(true);
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
it('releases the background layer immediately when the animation duration is 0s', async () => {
|
|
48
|
+
const el = await renderModal('<dile-modal style="--dile-modal-animation-duration: 0s">Modal content</dile-modal>');
|
|
49
|
+
el.open();
|
|
50
|
+
await el.updateComplete;
|
|
51
|
+
|
|
52
|
+
el.close();
|
|
53
|
+
await el.updateComplete;
|
|
54
|
+
|
|
55
|
+
const section = el.shadowRoot.querySelector('section');
|
|
56
|
+
expect(getComputedStyle(section).display).toBe('none');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('releases the background layer after the close animation even without transitionend', async () => {
|
|
60
|
+
const el = await renderModal('<dile-modal style="--dile-modal-animation-duration: 0.1s">Modal content</dile-modal>');
|
|
61
|
+
el.open();
|
|
62
|
+
await el.updateComplete;
|
|
63
|
+
|
|
64
|
+
el.close();
|
|
65
|
+
await el.updateComplete;
|
|
66
|
+
const section = el.shadowRoot.querySelector('section');
|
|
67
|
+
expect(getComputedStyle(section).pointerEvents).toBe('none');
|
|
68
|
+
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
70
|
+
await el.updateComplete;
|
|
71
|
+
expect(getComputedStyle(section).display).toBe('none');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ignores transitionend events coming from inner elements', async () => {
|
|
75
|
+
const el = await renderModal('<dile-modal>Modal content</dile-modal>');
|
|
76
|
+
el.open();
|
|
77
|
+
await el.updateComplete;
|
|
78
|
+
el.close();
|
|
79
|
+
await el.updateComplete;
|
|
80
|
+
|
|
81
|
+
const section = el.shadowRoot.querySelector('section');
|
|
82
|
+
const content = el.shadowRoot.querySelector('.content');
|
|
83
|
+
content.dispatchEvent(new TransitionEvent('transitionend', { bubbles: true, propertyName: 'opacity' }));
|
|
84
|
+
await el.updateComplete;
|
|
85
|
+
|
|
86
|
+
expect(section.classList.contains('opened')).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('dispatches dile-modal-closed and animates when the opened property is set to false', async () => {
|
|
90
|
+
const el = await renderModal('<dile-modal>Modal content</dile-modal>');
|
|
91
|
+
el.opened = true;
|
|
92
|
+
await el.updateComplete;
|
|
93
|
+
|
|
94
|
+
let closed = false;
|
|
95
|
+
el.addEventListener('dile-modal-closed', () => { closed = true; });
|
|
96
|
+
|
|
97
|
+
el.opened = false;
|
|
98
|
+
await el.updateComplete;
|
|
99
|
+
|
|
100
|
+
expect(closed).toBe(true);
|
|
101
|
+
expect(el.shadowRoot.querySelector('section').classList.contains('opened')).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('opens and closes through the opened attribute', async () => {
|
|
105
|
+
const el = await renderModal('<dile-modal>Modal content</dile-modal>');
|
|
106
|
+
let closed = false;
|
|
107
|
+
el.addEventListener('dile-modal-closed', () => { closed = true; });
|
|
108
|
+
|
|
109
|
+
el.setAttribute('opened', '');
|
|
110
|
+
await el.updateComplete;
|
|
111
|
+
expect(el.opened).toBe(true);
|
|
112
|
+
|
|
113
|
+
el.removeAttribute('opened');
|
|
114
|
+
await el.updateComplete;
|
|
115
|
+
expect(el.opened).toBe(false);
|
|
116
|
+
expect(closed).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reflects the opened property to the attribute', async () => {
|
|
120
|
+
const el = await renderModal('<dile-modal opened>Modal content</dile-modal>');
|
|
121
|
+
el.close();
|
|
122
|
+
await el.updateComplete;
|
|
123
|
+
expect(el.hasAttribute('opened')).toBe(false);
|
|
124
|
+
|
|
125
|
+
el.setAttribute('opened', '');
|
|
126
|
+
await el.updateComplete;
|
|
127
|
+
expect(el.opened).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('closes a blocking modal when Esc is pressed', async () => {
|
|
131
|
+
const el = await renderModal('<dile-modal blocking>Modal content</dile-modal>');
|
|
132
|
+
el.open();
|
|
133
|
+
await el.updateComplete;
|
|
134
|
+
|
|
135
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
136
|
+
await el.updateComplete;
|
|
137
|
+
|
|
138
|
+
expect(el.opened).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('closes only the topmost modal when Esc is pressed', async () => {
|
|
142
|
+
document.body.innerHTML = '<dile-modal id="first">First</dile-modal><dile-modal id="second">Second</dile-modal>';
|
|
143
|
+
const first = document.getElementById('first');
|
|
144
|
+
const second = document.getElementById('second');
|
|
145
|
+
await first.updateComplete;
|
|
146
|
+
await second.updateComplete;
|
|
147
|
+
first.open();
|
|
148
|
+
second.open();
|
|
149
|
+
|
|
150
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
151
|
+
expect(second.opened).toBe(false);
|
|
152
|
+
expect(first.opened).toBe(true);
|
|
153
|
+
|
|
154
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
155
|
+
expect(first.opened).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('exposes the content as an accessible modal dialog', async () => {
|
|
159
|
+
const el = await renderModal('<dile-modal label="Settings">Modal content</dile-modal>');
|
|
160
|
+
const dialog = el.shadowRoot.querySelector('[role="dialog"]');
|
|
161
|
+
|
|
162
|
+
expect(dialog).not.toBeNull();
|
|
163
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
|
164
|
+
expect(dialog.getAttribute('aria-label')).toBe('Settings');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('renders the close icon as an accessible button', async () => {
|
|
168
|
+
const el = await renderModal('<dile-modal showCloseIcon closeLabel="Cerrar">Modal content</dile-modal>');
|
|
169
|
+
el.open();
|
|
170
|
+
await el.updateComplete;
|
|
171
|
+
|
|
172
|
+
const button = el.shadowRoot.querySelector('button.closebutton');
|
|
173
|
+
expect(button.getAttribute('aria-label')).toBe('Cerrar');
|
|
174
|
+
|
|
175
|
+
button.click();
|
|
176
|
+
expect(el.opened).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('moves the focus into the modal and restores it on close', async () => {
|
|
180
|
+
document.body.innerHTML = '<button id="opener">Open</button><dile-modal>Modal content</dile-modal>';
|
|
181
|
+
const opener = document.getElementById('opener');
|
|
182
|
+
const el = document.querySelector('dile-modal');
|
|
183
|
+
await el.updateComplete;
|
|
184
|
+
|
|
185
|
+
opener.focus();
|
|
186
|
+
el.open();
|
|
187
|
+
await el.updateComplete;
|
|
188
|
+
expect(el.shadowRoot.activeElement).toBe(el.shadowRoot.querySelector('.content'));
|
|
189
|
+
|
|
190
|
+
el.close();
|
|
191
|
+
expect(document.activeElement).toBe(opener);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('keeps the focus on content focused by the user when opening', async () => {
|
|
195
|
+
const el = await renderModal('<dile-modal><input id="field"></dile-modal>');
|
|
196
|
+
const field = document.getElementById('field');
|
|
197
|
+
|
|
198
|
+
el.open();
|
|
199
|
+
await el.updateComplete;
|
|
200
|
+
field.focus();
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
202
|
+
|
|
203
|
+
expect(document.activeElement).toBe(field);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('dispatches dile-modal-opened when opened', async () => {
|
|
207
|
+
const el = await renderModal('<dile-modal>Modal content</dile-modal>');
|
|
208
|
+
let opened = false;
|
|
209
|
+
el.addEventListener('dile-modal-opened', () => { opened = true; });
|
|
210
|
+
|
|
211
|
+
el.open();
|
|
212
|
+
expect(opened).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('becomes opaque after opening', async () => {
|
|
216
|
+
const el = await renderModal('<dile-modal>Modal content</dile-modal>');
|
|
217
|
+
el.open();
|
|
218
|
+
await el.updateComplete;
|
|
219
|
+
|
|
220
|
+
const section = el.shadowRoot.querySelector('section');
|
|
221
|
+
await expect.poll(() => section.classList.contains('opaque')).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('locks the page scroll while any modal is opened', async () => {
|
|
225
|
+
document.body.innerHTML = '<dile-modal id="first">First</dile-modal><dile-modal id="second">Second</dile-modal>';
|
|
226
|
+
const first = document.getElementById('first');
|
|
227
|
+
const second = document.getElementById('second');
|
|
228
|
+
await second.updateComplete;
|
|
229
|
+
const root = document.documentElement;
|
|
230
|
+
|
|
231
|
+
first.open();
|
|
232
|
+
second.open();
|
|
233
|
+
expect(root.style.overflow).toBe('hidden');
|
|
234
|
+
|
|
235
|
+
second.close();
|
|
236
|
+
expect(root.style.overflow).toBe('hidden');
|
|
237
|
+
|
|
238
|
+
first.close();
|
|
239
|
+
expect(root.style.overflow).toBe('');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('unlocks the page scroll when an opened modal is removed from the document', async () => {
|
|
243
|
+
const el = await renderModal('<dile-modal>Modal content</dile-modal>');
|
|
244
|
+
el.open();
|
|
245
|
+
expect(document.documentElement.style.overflow).toBe('hidden');
|
|
246
|
+
|
|
247
|
+
el.remove();
|
|
248
|
+
expect(document.documentElement.style.overflow).toBe('');
|
|
249
|
+
});
|
|
46
250
|
});
|
|
@@ -1,33 +1,72 @@
|
|
|
1
1
|
import { LitElement, html, css } from "lit";
|
|
2
|
+
import { ifDefined } from "lit/directives/if-defined.js";
|
|
2
3
|
import '../../icon/icon.js';
|
|
3
4
|
import { closeIcon } from '@dile/icons/index.js'
|
|
4
5
|
import { DileCloseOnEscPressed } from '../../../mixins/close-on-esc-pressed/index.js'
|
|
5
6
|
|
|
7
|
+
// Opened modals, in opening order. The last one is the topmost modal.
|
|
8
|
+
const openedModals = [];
|
|
9
|
+
// Escape keydown events already handled by a modal.
|
|
10
|
+
const handledEscEvents = new WeakSet();
|
|
11
|
+
// Inline styles of the document before locking the page scroll.
|
|
12
|
+
let scrollLockBackup = null;
|
|
13
|
+
|
|
14
|
+
// Locks the page scroll while any modal is opened.
|
|
15
|
+
function updateScrollLock() {
|
|
16
|
+
const root = document.documentElement;
|
|
17
|
+
if (openedModals.length && !scrollLockBackup) {
|
|
18
|
+
const scrollbarWidth = window.innerWidth - root.clientWidth;
|
|
19
|
+
scrollLockBackup = {
|
|
20
|
+
overflow: root.style.overflow,
|
|
21
|
+
paddingRight: root.style.paddingRight,
|
|
22
|
+
};
|
|
23
|
+
root.style.overflow = 'hidden';
|
|
24
|
+
if (scrollbarWidth > 0) {
|
|
25
|
+
// Avoids the layout shift caused by the scrollbar disappearing.
|
|
26
|
+
const paddingRight = parseFloat(getComputedStyle(root).paddingRight) || 0;
|
|
27
|
+
root.style.paddingRight = `${paddingRight + scrollbarWidth}px`;
|
|
28
|
+
}
|
|
29
|
+
} else if (!openedModals.length && scrollLockBackup) {
|
|
30
|
+
root.style.overflow = scrollLockBackup.overflow;
|
|
31
|
+
root.style.paddingRight = scrollLockBackup.paddingRight;
|
|
32
|
+
scrollLockBackup = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
6
36
|
export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
7
37
|
static get properties() {
|
|
8
38
|
return {
|
|
9
|
-
_toChange: {
|
|
39
|
+
_toChange: { state: true },
|
|
10
40
|
/**
|
|
11
41
|
* If true the modal box is in opened status.
|
|
12
42
|
*/
|
|
13
|
-
opened: { type: Boolean },
|
|
43
|
+
opened: { type: Boolean, reflect: true, noAccessor: true },
|
|
14
44
|
/**
|
|
15
45
|
* If true the modal box displays a close icon
|
|
16
46
|
*/
|
|
17
47
|
showCloseIcon: { type: Boolean },
|
|
18
48
|
/**
|
|
19
|
-
* If true the modal box blocks the screen. That is, when you click in the background layer, the modal box
|
|
49
|
+
* If true the modal box blocks the screen. That is, when you click in the background layer, the modal box does not close.
|
|
20
50
|
*/
|
|
21
51
|
blocking: { type: Boolean },
|
|
52
|
+
/**
|
|
53
|
+
* Accessible name of the modal box, announced by screen readers when it opens.
|
|
54
|
+
*/
|
|
55
|
+
label: { type: String },
|
|
56
|
+
/**
|
|
57
|
+
* Accessible label of the close icon button.
|
|
58
|
+
*/
|
|
59
|
+
closeLabel: { type: String },
|
|
22
60
|
};
|
|
23
61
|
}
|
|
24
62
|
|
|
25
63
|
constructor() {
|
|
26
64
|
super();
|
|
27
65
|
this._toChange = false;
|
|
28
|
-
this.
|
|
66
|
+
this._opened = false;
|
|
29
67
|
this.showCloseIcon = false;
|
|
30
68
|
this.blocking = false;
|
|
69
|
+
this.closeLabel = 'Close';
|
|
31
70
|
}
|
|
32
71
|
|
|
33
72
|
static get styles() {
|
|
@@ -52,11 +91,10 @@ export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
|
52
91
|
left: 0;
|
|
53
92
|
top: 0;
|
|
54
93
|
height: 100vh;
|
|
94
|
+
height: 100dvh;
|
|
55
95
|
width: 100vw;
|
|
56
96
|
display: none;
|
|
57
97
|
transition: opacity var(--dile-modal-animation-duration, 0.3s) ease-in;
|
|
58
|
-
-webkit-transition: opacity var(--dile-modal-animation-duration, 0.3s)
|
|
59
|
-
ease-in;
|
|
60
98
|
align-items: center;
|
|
61
99
|
justify-content: center;
|
|
62
100
|
z-index: var(--dile-modal-z-index, 100);
|
|
@@ -87,13 +125,13 @@ export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
|
87
125
|
}
|
|
88
126
|
article {
|
|
89
127
|
overflow: auto;
|
|
90
|
-
max-height: 100%;
|
|
91
128
|
padding-right: var(--dile-modal-content-padding, 1em);
|
|
92
129
|
height: var(--dile-modal-height, auto);
|
|
93
130
|
max-height: var(--dile-modal-max-height, 90vh);
|
|
94
131
|
}
|
|
95
132
|
.transparent {
|
|
96
133
|
opacity: 0;
|
|
134
|
+
pointer-events: none;
|
|
97
135
|
}
|
|
98
136
|
.opaque {
|
|
99
137
|
opacity: 1;
|
|
@@ -102,12 +140,20 @@ export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
|
102
140
|
.opened {
|
|
103
141
|
display: flex !important;
|
|
104
142
|
}
|
|
105
|
-
|
|
143
|
+
.content:focus {
|
|
144
|
+
outline: none;
|
|
145
|
+
}
|
|
146
|
+
.closebutton {
|
|
106
147
|
position: absolute;
|
|
107
|
-
display: inline-
|
|
148
|
+
display: inline-flex;
|
|
108
149
|
top: var(--dile-modal-close-icon-top, 5px);
|
|
109
150
|
right: var(--dile-modal-close-icon-right, 18px);
|
|
110
151
|
z-index: 1002;
|
|
152
|
+
padding: 0;
|
|
153
|
+
margin: 0;
|
|
154
|
+
border: none;
|
|
155
|
+
background: none;
|
|
156
|
+
color: inherit;
|
|
111
157
|
cursor: var(--dile-modal-close-icon-cursor, pointer);
|
|
112
158
|
}
|
|
113
159
|
.contentIconSeparation {
|
|
@@ -125,9 +171,17 @@ export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
|
125
171
|
@transitionend="${this.animationEnd}"
|
|
126
172
|
id="backgroundmodal"
|
|
127
173
|
>
|
|
128
|
-
<div
|
|
174
|
+
<div
|
|
175
|
+
class="content"
|
|
176
|
+
role="dialog"
|
|
177
|
+
aria-modal="true"
|
|
178
|
+
aria-label="${ifDefined(this.label)}"
|
|
179
|
+
tabindex="-1"
|
|
180
|
+
>
|
|
129
181
|
${this.showCloseIcon
|
|
130
|
-
? html`<
|
|
182
|
+
? html`<button type="button" class="closebutton" aria-label="${this.closeLabel}" @click="${this.close}">
|
|
183
|
+
<dile-icon .icon="${closeIcon}" class="closeicon"></dile-icon>
|
|
184
|
+
</button>`
|
|
131
185
|
: ""}
|
|
132
186
|
<article class="${this.showCloseIcon ? "contentIconSeparation" : ""}">
|
|
133
187
|
<slot></slot>
|
|
@@ -153,12 +207,29 @@ export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
|
153
207
|
return '';
|
|
154
208
|
}
|
|
155
209
|
|
|
210
|
+
get opened() {
|
|
211
|
+
return this._opened;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The opened property can be changed by open() / close(), or directly
|
|
215
|
+
// by the property or the attribute. All ways run the same transitions.
|
|
216
|
+
set opened(value) {
|
|
217
|
+
const oldValue = this._opened;
|
|
218
|
+
value = Boolean(value);
|
|
219
|
+
if (value === oldValue) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this._opened = value;
|
|
223
|
+
this.requestUpdate('opened', oldValue);
|
|
224
|
+
if (value) {
|
|
225
|
+
this._startOpening();
|
|
226
|
+
} else {
|
|
227
|
+
this._startClosing();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
156
231
|
open() {
|
|
157
232
|
this.opened = true;
|
|
158
|
-
this._toChange = true;
|
|
159
|
-
setTimeout(() => {
|
|
160
|
-
this._toChange = false;
|
|
161
|
-
}, 50);
|
|
162
233
|
}
|
|
163
234
|
|
|
164
235
|
_backgroundModalClick(e) {
|
|
@@ -175,22 +246,166 @@ export class DileModal extends DileCloseOnEscPressed(LitElement) {
|
|
|
175
246
|
}
|
|
176
247
|
|
|
177
248
|
close() {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
249
|
+
this.opened = false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_startOpening() {
|
|
253
|
+
this._clearCloseTimeout();
|
|
254
|
+
this._removeFromOpenedModals();
|
|
255
|
+
if (this.isConnected) {
|
|
256
|
+
this._addToOpenedModals();
|
|
257
|
+
}
|
|
258
|
+
this._toChange = true;
|
|
259
|
+
this._cancelOpenFrame();
|
|
260
|
+
// Waits for the layer to be painted with opacity 0, so the fade-in transition runs.
|
|
261
|
+
this.updateComplete.then(() => {
|
|
262
|
+
this._openFrame = requestAnimationFrame(() => {
|
|
263
|
+
this._openFrame = requestAnimationFrame(() => {
|
|
264
|
+
this._openFrame = null;
|
|
265
|
+
if (this.opened) {
|
|
266
|
+
this._toChange = false;
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
this._previousFocus = this._getDeepActiveElement();
|
|
272
|
+
this.updateComplete.then(() => this._focusContent());
|
|
273
|
+
this.dispatchEvent(
|
|
274
|
+
new CustomEvent("dile-modal-opened", {
|
|
275
|
+
bubbles: true,
|
|
276
|
+
composed: true,
|
|
277
|
+
detail: this,
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
_cancelOpenFrame() {
|
|
283
|
+
if (this._openFrame) {
|
|
284
|
+
cancelAnimationFrame(this._openFrame);
|
|
285
|
+
this._openFrame = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Moves the focus inside the modal, unless the content already took it.
|
|
290
|
+
_focusContent() {
|
|
291
|
+
if (!this.opened || this.matches(':focus-within')) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.shadowRoot.querySelector('.content')?.focus({ preventScroll: true });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Returns the focus to the element that had it before opening the modal.
|
|
298
|
+
_restoreFocus() {
|
|
299
|
+
const previousFocus = this._previousFocus;
|
|
300
|
+
this._previousFocus = null;
|
|
301
|
+
const active = this._getDeepActiveElement();
|
|
302
|
+
const focusLost = !active || active === document.body;
|
|
303
|
+
if (!previousFocus?.isConnected || !(focusLost || this.matches(':focus-within'))) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
previousFocus.focus({ preventScroll: true });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_getDeepActiveElement() {
|
|
310
|
+
let active = document.activeElement;
|
|
311
|
+
while (active?.shadowRoot?.activeElement) {
|
|
312
|
+
active = active.shadowRoot.activeElement;
|
|
313
|
+
}
|
|
314
|
+
return active;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_startClosing() {
|
|
318
|
+
this._cancelOpenFrame();
|
|
319
|
+
this._removeFromOpenedModals();
|
|
320
|
+
this._toChange = true;
|
|
321
|
+
this._restoreFocus();
|
|
322
|
+
this._scheduleCloseEnd();
|
|
323
|
+
this.dispatchEvent(
|
|
324
|
+
new CustomEvent("dile-modal-closed", {
|
|
325
|
+
bubbles: true,
|
|
326
|
+
composed: true,
|
|
327
|
+
detail: this,
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_removeFromOpenedModals() {
|
|
333
|
+
const index = openedModals.indexOf(this);
|
|
334
|
+
if (index !== -1) {
|
|
335
|
+
openedModals.splice(index, 1);
|
|
336
|
+
updateScrollLock();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_addToOpenedModals() {
|
|
341
|
+
if (!openedModals.includes(this)) {
|
|
342
|
+
openedModals.push(this);
|
|
343
|
+
updateScrollLock();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Only the topmost modal reacts to the Esc key.
|
|
348
|
+
escClose(e) {
|
|
349
|
+
if (e.key !== "Escape" || !this.opened || handledEscEvents.has(e)) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (openedModals[openedModals.length - 1] !== this) {
|
|
353
|
+
return;
|
|
188
354
|
}
|
|
355
|
+
handledEscEvents.add(e);
|
|
356
|
+
this.close();
|
|
189
357
|
}
|
|
190
358
|
|
|
191
|
-
animationEnd() {
|
|
359
|
+
animationEnd(e) {
|
|
360
|
+
if (e && (e.target !== e.currentTarget || e.propertyName !== 'opacity')) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this._clearCloseTimeout();
|
|
192
364
|
this._toChange = false;
|
|
193
365
|
}
|
|
194
366
|
|
|
195
|
-
|
|
367
|
+
connectedCallback() {
|
|
368
|
+
super.connectedCallback();
|
|
369
|
+
if (this.opened) {
|
|
370
|
+
this._addToOpenedModals();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
disconnectedCallback() {
|
|
375
|
+
super.disconnectedCallback();
|
|
376
|
+
this._clearCloseTimeout();
|
|
377
|
+
this._cancelOpenFrame();
|
|
378
|
+
this._removeFromOpenedModals();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// transitionend is not fired when the transition duration is 0 (or the
|
|
382
|
+
// transition is interrupted), so the background layer is also released by a timer.
|
|
383
|
+
_scheduleCloseEnd() {
|
|
384
|
+
this._clearCloseTimeout();
|
|
385
|
+
const duration = this._getAnimationDuration();
|
|
386
|
+
if (duration <= 0) {
|
|
387
|
+
this.animationEnd();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
this._closeTimeout = setTimeout(() => this.animationEnd(), duration + 50);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
_clearCloseTimeout() {
|
|
394
|
+
if (this._closeTimeout) {
|
|
395
|
+
clearTimeout(this._closeTimeout);
|
|
396
|
+
this._closeTimeout = null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
_getAnimationDuration() {
|
|
401
|
+
const section = this.shadowRoot?.querySelector('section');
|
|
402
|
+
if (!section) {
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
const durations = getComputedStyle(section).transitionDuration.split(',');
|
|
406
|
+
return Math.max(...durations.map((value) => {
|
|
407
|
+
const time = parseFloat(value) || 0;
|
|
408
|
+
return value.trim().endsWith('ms') ? time : time * 1000;
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
196
411
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dile/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.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": "
|
|
31
|
+
"gitHead": "0f1a6553d287b706db6f42c7001a25fdebc3bd3a"
|
|
32
32
|
}
|