Commit | Line | Data |
---|---|---|
a8d59163 AE |
1 | /** |
2 | * The web component `<woltlab-core-dialog>` represents a | |
3 | * modal dialog with a unified event access for consistent | |
4 | * interactions. This is the low-level API of dialogs, you | |
5 | * should use the `dialogFactory()` to create them. | |
6 | * | |
7 | * @author Alexander Ebert | |
8 | * @copyright 2001-2022 WoltLab GmbH | |
9 | * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> | |
10 | * @since 6.0 | |
11 | */ | |
12 | ||
88e5c955 | 13 | import DomUtil from "../Dom/Util"; |
c7b9c92a | 14 | import { adoptPageOverlayContainer, releasePageOverlayContainer } from "../Helper/PageOverlay"; |
db355ae2 | 15 | import * as Language from "../Language"; |
945ec217 | 16 | import { scrollDisable, scrollEnable } from "../Ui/Screen"; |
88e5c955 | 17 | |
66e55374 AE |
18 | type ValidateCallback = Promise<boolean>; |
19 | ||
51a87686 | 20 | interface WoltlabCoreDialogEventMap { |
1b1ff3cf | 21 | afterClose: CustomEvent; |
d18beb61 | 22 | backdrop: CustomEvent; |
1b1ff3cf AE |
23 | cancel: CustomEvent; |
24 | close: CustomEvent; | |
23148f48 | 25 | extra: CustomEvent; |
1b1ff3cf | 26 | primary: CustomEvent; |
66e55374 | 27 | validate: CustomEvent<ValidateCallback[]>; |
1b1ff3cf AE |
28 | } |
29 | ||
da04556a AE |
30 | const dialogContainer = document.createElement("div"); |
31 | ||
edf01062 | 32 | export type WoltlabCoreDialogControlOptions = { |
ddd2ef5a AE |
33 | cancel: string | undefined; |
34 | extra: string | undefined; | |
35 | isAlert: boolean; | |
5badf4ef AE |
36 | primary: string; |
37 | }; | |
38 | ||
e768e76b | 39 | // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging |
51a87686 | 40 | export class WoltlabCoreDialogElement extends HTMLElement { |
71fbd0a0 | 41 | readonly #content: HTMLElement; |
88e5c955 | 42 | readonly #dialog: HTMLDialogElement; |
5badf4ef | 43 | #form?: HTMLFormElement; |
88e5c955 AE |
44 | readonly #title: HTMLElement; |
45 | ||
46 | constructor() { | |
47 | super(); | |
48 | ||
71fbd0a0 | 49 | this.#content = document.createElement("div"); |
88e5c955 | 50 | this.#dialog = document.createElement("dialog"); |
da04556a | 51 | this.#title = document.createElement("div"); |
88e5c955 AE |
52 | } |
53 | ||
5c658a8f AE |
54 | show(title: string): void { |
55 | if (title.trim().length === 0) { | |
88e5c955 AE |
56 | throw new Error("Cannot open the modal dialog without a title."); |
57 | } | |
58 | ||
5c658a8f AE |
59 | this.#title.textContent = title; |
60 | ||
8ce5eada AE |
61 | if (this.open) { |
62 | return; | |
63 | } | |
64 | ||
a813abc9 AE |
65 | if (dialogContainer.parentElement === null) { |
66 | document.getElementById("content")!.append(dialogContainer); | |
67 | } | |
da04556a | 68 | |
a813abc9 | 69 | if (this.parentElement !== dialogContainer) { |
da04556a AE |
70 | dialogContainer.append(this); |
71 | } | |
72 | ||
88e5c955 | 73 | this.#dialog.showModal(); |
c7b9c92a AE |
74 | |
75 | adoptPageOverlayContainer(this.#dialog); | |
945ec217 | 76 | scrollDisable(); |
88e5c955 AE |
77 | } |
78 | ||
79 | close(): void { | |
80 | this.#dialog.close(); | |
81 | ||
00d59dfd AE |
82 | this.#detachDialog(); |
83 | } | |
84 | ||
85 | #detachDialog(): void { | |
3059bd99 AE |
86 | if (this.parentNode === null) { |
87 | return; | |
88 | } | |
89 | ||
1b1ff3cf | 90 | const event = new CustomEvent("afterClose"); |
0b783904 | 91 | this.dispatchEvent(event); |
c7b9c92a AE |
92 | |
93 | releasePageOverlayContainer(this.#dialog); | |
945ec217 | 94 | scrollEnable(); |
a813abc9 AE |
95 | |
96 | // Remove the dialog from the DOM, preventing it from | |
97 | // causing any collisions caused by elements with IDs | |
98 | // contained inside it. Will also cause the DOM element | |
99 | // to be garbage collected when there are no more | |
100 | // references to it. | |
101 | this.remove(); | |
88e5c955 AE |
102 | } |
103 | ||
104 | get dialog(): HTMLDialogElement { | |
105 | return this.#dialog; | |
106 | } | |
107 | ||
108 | get content(): HTMLElement { | |
88e5c955 AE |
109 | return this.#content; |
110 | } | |
111 | ||
88e5c955 AE |
112 | get open(): boolean { |
113 | return this.#dialog.open; | |
114 | } | |
115 | ||
7dad5d19 AE |
116 | get incomplete(): boolean { |
117 | return this.hasAttribute("incomplete"); | |
118 | } | |
119 | ||
120 | set incomplete(incomplete: boolean) { | |
121 | if (incomplete) { | |
122 | this.setAttribute("incomplete", ""); | |
123 | } else { | |
124 | this.removeAttribute("incomplete"); | |
125 | } | |
126 | } | |
127 | ||
edf01062 | 128 | attachControls(options: WoltlabCoreDialogControlOptions): void { |
5badf4ef AE |
129 | if (this.#form !== undefined) { |
130 | throw new Error("There is already a form control attached to this dialog."); | |
131 | } | |
132 | ||
ddd2ef5a AE |
133 | if (options.extra !== undefined && options.cancel === undefined) { |
134 | options.cancel = ""; | |
135 | } | |
136 | ||
31998c1c | 137 | const formControl = document.createElement("woltlab-core-dialog-control"); |
5badf4ef AE |
138 | formControl.primary = options.primary; |
139 | ||
ddd2ef5a AE |
140 | if (options.cancel !== undefined) { |
141 | formControl.cancel = options.cancel; | |
142 | } | |
143 | ||
23148f48 AE |
144 | if (options.extra !== undefined) { |
145 | formControl.extra = options.extra; | |
146 | } | |
147 | ||
5badf4ef AE |
148 | this.#form = document.createElement("form"); |
149 | this.#form.method = "dialog"; | |
6c71b0d2 | 150 | this.#form.classList.add("dialog__form"); |
5badf4ef AE |
151 | this.#content.insertAdjacentElement("beforebegin", this.#form); |
152 | ||
153 | this.#form.append(this.#content, formControl); | |
6c71b0d2 | 154 | |
ddd2ef5a AE |
155 | if (options.isAlert) { |
156 | if (options.cancel === undefined) { | |
157 | this.#dialog.setAttribute("role", "alert"); | |
158 | } else { | |
159 | this.#dialog.setAttribute("role", "alertdialog"); | |
160 | } | |
161 | } | |
1b1ff3cf AE |
162 | |
163 | this.#form.addEventListener("submit", (event) => { | |
7dad5d19 AE |
164 | if (this.incomplete) { |
165 | event.preventDefault(); | |
166 | return; | |
167 | } | |
168 | ||
66e55374 AE |
169 | const callbacks: ValidateCallback[] = []; |
170 | const evt = new CustomEvent("validate", { | |
171 | cancelable: true, | |
172 | detail: callbacks, | |
173 | }); | |
1b1ff3cf AE |
174 | this.dispatchEvent(evt); |
175 | ||
fa29f504 | 176 | // Canceling this event is interpreted as a form validation failure. |
1b1ff3cf AE |
177 | if (evt.defaultPrevented) { |
178 | event.preventDefault(); | |
fa29f504 | 179 | return; |
1b1ff3cf | 180 | } |
66e55374 AE |
181 | |
182 | if (evt.detail.length > 0) { | |
183 | // DOM events cannot wait for async functions. We must | |
184 | // reject the event and then wait for the async | |
185 | // callbacks to complete. | |
186 | event.preventDefault(); | |
187 | ||
188 | // Blocking further attempts to submit the dialog | |
189 | // while the validation is running. | |
190 | this.incomplete = true; | |
191 | ||
192 | void Promise.all(evt.detail).then((results) => { | |
193 | this.incomplete = false; | |
194 | ||
195 | const failedValidation = results.some((result) => result === false); | |
196 | if (!failedValidation) { | |
197 | // The `primary` event is triggered once the validation | |
198 | // has completed. Triggering the submit again would cause | |
199 | // `validate` to run again, causing an infinite loop. | |
200 | this.#dispatchPrimaryEvent(); | |
00d59dfd | 201 | |
27809f3d MM |
202 | if (this.#shouldClose()) { |
203 | this.close(); | |
204 | } | |
66e55374 AE |
205 | } |
206 | }); | |
207 | } | |
fa29f504 MM |
208 | // There were no validation handlers to process, so validation has passed. |
209 | // By default the browser will close the dialog unless the submit event’s default action gets prevented. | |
210 | else if (!this.#shouldClose()) { | |
27809f3d MM |
211 | // Prevent the browser from closing the dialog |
212 | event.preventDefault(); | |
213 | // but dispatch the `primary` event | |
214 | this.#dispatchPrimaryEvent(); | |
215 | } | |
1b1ff3cf AE |
216 | }); |
217 | ||
218 | this.#dialog.addEventListener("close", () => { | |
219 | if (this.#dialog.returnValue === "") { | |
158d9dbf | 220 | // Dialog was programmatically closed. |
3059bd99 AE |
221 | } else { |
222 | this.#dispatchPrimaryEvent(); | |
1b1ff3cf AE |
223 | } |
224 | ||
00d59dfd | 225 | this.#detachDialog(); |
1b1ff3cf AE |
226 | }); |
227 | ||
228 | formControl.addEventListener("cancel", () => { | |
229 | const event = new CustomEvent("cancel", { cancelable: true }); | |
230 | this.dispatchEvent(event); | |
231 | ||
27809f3d | 232 | if (!event.defaultPrevented && this.#shouldClose()) { |
1b1ff3cf AE |
233 | this.close(); |
234 | } | |
235 | }); | |
23148f48 AE |
236 | |
237 | if (options.extra !== undefined) { | |
238 | formControl.addEventListener("extra", () => { | |
239 | const event = new CustomEvent("extra"); | |
240 | this.dispatchEvent(event); | |
241 | }); | |
242 | } | |
5badf4ef AE |
243 | } |
244 | ||
66e55374 AE |
245 | #dispatchPrimaryEvent(): void { |
246 | const evt = new CustomEvent("primary"); | |
247 | this.dispatchEvent(evt); | |
248 | } | |
249 | ||
158d9dbf | 250 | connectedCallback(): void { |
88e5c955 AE |
251 | if (this.#dialog.parentElement !== null) { |
252 | return; | |
253 | } | |
254 | ||
56a41a77 | 255 | let closeButton: HTMLButtonElement | undefined; |
8a92d495 AE |
256 | const dialogRole = this.#dialog.getAttribute("role"); |
257 | if (dialogRole !== "alert" && dialogRole !== "alertdialog") { | |
56a41a77 AE |
258 | closeButton = document.createElement("button"); |
259 | closeButton.innerHTML = '<fa-icon size="24" name="xmark"></fa-icon>'; | |
db355ae2 AE |
260 | closeButton.classList.add("dialog__closeButton", "jsTooltip"); |
261 | closeButton.title = Language.get("wcf.dialog.button.close"); | |
56a41a77 | 262 | closeButton.addEventListener("click", () => { |
27809f3d MM |
263 | if (this.#shouldClose()) { |
264 | this.close(); | |
265 | } | |
56a41a77 AE |
266 | }); |
267 | } | |
88e5c955 AE |
268 | |
269 | const header = document.createElement("div"); | |
6bbeaa68 AE |
270 | header.classList.add("dialog__header"); |
271 | this.#title.classList.add("dialog__title"); | |
56a41a77 AE |
272 | header.append(this.#title); |
273 | if (closeButton) { | |
274 | header.append(closeButton); | |
275 | } | |
88e5c955 AE |
276 | |
277 | const doc = document.createElement("div"); | |
6bbeaa68 | 278 | doc.classList.add("dialog__document"); |
88e5c955 | 279 | doc.setAttribute("role", "document"); |
5badf4ef | 280 | doc.append(header); |
71fbd0a0 AE |
281 | |
282 | this.#content.classList.add("dialog__content"); | |
5badf4ef AE |
283 | if (this.#form) { |
284 | doc.append(this.#form); | |
285 | } else { | |
286 | doc.append(this.#content); | |
287 | } | |
88e5c955 AE |
288 | |
289 | this.#dialog.append(doc); | |
6bbeaa68 | 290 | this.#dialog.classList.add("dialog"); |
88e5c955 AE |
291 | this.#dialog.setAttribute("aria-labelledby", DomUtil.identify(this.#title)); |
292 | ||
27809f3d MM |
293 | this.#dialog.addEventListener("cancel", (event) => { |
294 | const evt = new CustomEvent("cancel", { cancelable: true }); | |
295 | this.dispatchEvent(evt); | |
2ceeb590 | 296 | |
27809f3d MM |
297 | if (evt.defaultPrevented) { |
298 | event.preventDefault(); | |
299 | return; | |
300 | } | |
301 | ||
302 | if (this.#shouldClose()) { | |
303 | this.#detachDialog(); | |
fa29f504 | 304 | } else { |
27809f3d MM |
305 | // Prevent the browser from closing the dialog. |
306 | event.preventDefault(); | |
307 | } | |
88e5c955 AE |
308 | }); |
309 | ||
da04556a | 310 | // Close the dialog by clicking on the backdrop. |
0b783904 AE |
311 | // |
312 | // Using the `close` event is not an option because it will | |
313 | // also trigger when holding the mouse button inside the | |
314 | // dialog and then releasing it on the backdrop. | |
315 | this.#dialog.addEventListener("mousedown", (event) => { | |
da04556a | 316 | if (event.target === this.#dialog) { |
27809f3d MM |
317 | const evt = new CustomEvent("backdrop", { cancelable: true }); |
318 | this.dispatchEvent(evt); | |
319 | if (evt.defaultPrevented) { | |
d18beb61 AE |
320 | return; |
321 | } | |
322 | ||
0b783904 AE |
323 | if (this.#shouldClose()) { |
324 | this.close(); | |
325 | } | |
da04556a AE |
326 | } |
327 | }); | |
88e5c955 | 328 | |
da04556a | 329 | this.append(this.#dialog); |
88e5c955 | 330 | } |
0b783904 AE |
331 | |
332 | #shouldClose(): boolean { | |
27809f3d | 333 | const event = new CustomEvent("close", { cancelable: true }); |
0b783904 AE |
334 | this.dispatchEvent(event); |
335 | ||
336 | return event.defaultPrevented === false; | |
337 | } | |
8f9bad49 | 338 | } |
1b1ff3cf | 339 | |
e768e76b | 340 | // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging |
8f9bad49 TD |
341 | export interface WoltlabCoreDialogElement extends HTMLElement { |
342 | addEventListener: { | |
343 | <T extends keyof WoltlabCoreDialogEventMap>( | |
344 | type: T, | |
345 | listener: (this: WoltlabCoreDialogElement, ev: WoltlabCoreDialogEventMap[T]) => any, | |
346 | options?: boolean | AddEventListenerOptions, | |
347 | ): void; | |
348 | } & HTMLElement["addEventListener"]; | |
88e5c955 | 349 | } |
da04556a | 350 | |
15397da0 | 351 | window.customElements.define("woltlab-core-dialog", WoltlabCoreDialogElement); |
31998c1c | 352 | |
15397da0 | 353 | export default WoltlabCoreDialogElement; |