@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.
Files changed (15) hide show
  1. package/components/drop-file/drop-file.spec.js +102 -0
  2. package/components/drop-file/src/DileDropFile.js +84 -7
  3. package/components/modal/__screenshots__/modal.spec.js/dile-modal-closes-only-the-topmost-modal-when-Esc-is-pressed-1.png +0 -0
  4. 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
  5. package/components/modal/__screenshots__/modal.spec.js/dile-modal-does-not-close-a-blocking-modal-when-Esc-is-pressed-1.png +0 -0
  6. 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
  7. package/components/modal/__screenshots__/modal.spec.js/dile-modal-ignores-transitionend-events-coming-from-inner-elements-1.png +0 -0
  8. package/components/modal/__screenshots__/modal.spec.js/dile-modal-keeps-the-focus-on-content-focused-by-the-user-when-opening-1.png +0 -0
  9. package/components/modal/__screenshots__/modal.spec.js/dile-modal-opens-and-closes-through-the-opened-attribute-1.png +0 -0
  10. package/components/modal/__screenshots__/modal.spec.js/dile-modal-reflects-the-opened-property-to-the-attribute-1.png +0 -0
  11. package/components/modal/__screenshots__/modal.spec.js/dile-modal-releases-the-background-layer-after-the-close-animation-even-without-transitionend-1.png +0 -0
  12. package/components/modal/__screenshots__/modal.spec.js/dile-modal-releases-the-background-layer-immediately-when-the-animation-duration-is-0s-1.png +0 -0
  13. package/components/modal/modal.spec.js +204 -0
  14. package/components/modal/src/DileModal.js +242 -27
  15. 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
- <dile-file-preview
114
- fileName="${this.fileName}"
115
- selectedFileLabel="${this.selectedFileLabel}"
116
- @dile-file-clear=${this.clear}
117
- ></dile-file-preview>
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 {
@@ -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: { type: Boolean },
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 do not close.
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.opened = false;
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
- dile-icon {
143
+ .content:focus {
144
+ outline: none;
145
+ }
146
+ .closebutton {
106
147
  position: absolute;
107
- display: inline-block;
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 class="content" @click="${this.contentClick}">
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`<dile-icon .icon="${closeIcon}" @click="${this.close}" class="closeicon"></dile-icon>`
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
- if(this.opened) {
179
- this.opened = false;
180
- this._toChange = true;
181
- this.dispatchEvent(
182
- new CustomEvent("dile-modal-closed", {
183
- bubbles: true,
184
- composed: true,
185
- detail: this,
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.15.0",
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": "5ef91e1892e270fade848c64082184fa3fbddb53"
31
+ "gitHead": "0f1a6553d287b706db6f42c7001a25fdebc3bd3a"
32
32
  }