Merge remote-tracking branch 'origin/6.0'
[GitHub/WoltLab/WCF.git] / ts / WoltLabSuite / Core / Element / woltlab-core-dialog.ts
CommitLineData
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 13import DomUtil from "../Dom/Util";
c7b9c92a 14import { adoptPageOverlayContainer, releasePageOverlayContainer } from "../Helper/PageOverlay";
db355ae2 15import * as Language from "../Language";
945ec217 16import { scrollDisable, scrollEnable } from "../Ui/Screen";
88e5c955 17
66e55374
AE
18type ValidateCallback = Promise<boolean>;
19
51a87686 20interface 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
30const dialogContainer = document.createElement("div");
31
edf01062 32export 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 40export 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
341export 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 351window.customElements.define("woltlab-core-dialog", WoltlabCoreDialogElement);
31998c1c 352
15397da0 353export default WoltlabCoreDialogElement;