@cedx/base 0.16.0 → 0.18.0

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.
@@ -0,0 +1,417 @@
1
+ import {Modal} from "bootstrap";
2
+ import {Context, getIcon, toCss} from "../Context.js";
3
+ import {DialogResult} from "../DialogResult.js";
4
+ import {createDocumentFragment} from "../ElementExtensions.js";
5
+ import {Variant} from "../Variant.js";
6
+ import type {DialogButton, IDialogButton} from "./DialogButton.js";
7
+
8
+ /**
9
+ * Represents a message to display in a dialog box.
10
+ */
11
+ export interface IMessage {
12
+
13
+ /**
14
+ * Value indicating whether to apply a fade transition.
15
+ */
16
+ animation?: boolean;
17
+
18
+ /**
19
+ * The child content displayed in the body.
20
+ */
21
+ body: DocumentFragment|string;
22
+
23
+ /**
24
+ * The title displayed in the header.
25
+ */
26
+ caption: string;
27
+
28
+ /**
29
+ * Value indicating whether to vertically center this message box.
30
+ */
31
+ centered?: boolean;
32
+
33
+ /**
34
+ * A contextual modifier.
35
+ */
36
+ context?: Context;
37
+
38
+ /**
39
+ * The child content displayed in the footer.
40
+ */
41
+ footer?: DocumentFragment|string;
42
+
43
+ /**
44
+ * The icon displayed next to the body.
45
+ */
46
+ icon?: string|null;
47
+
48
+ /**
49
+ * Value indicating whether the body is scrollable.
50
+ */
51
+ scrollable?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Displays a message window, also known as dialog box, which presents a message to the user.
56
+ */
57
+ export class MessageBox extends HTMLElement {
58
+
59
+ /**
60
+ * The list of observed attributes.
61
+ */
62
+ static readonly observedAttributes = ["animation", "caption", "centered", "context", "icon", "modal", "scrollable"];
63
+
64
+ /**
65
+ * The template for a button.
66
+ */
67
+ readonly #buttonTemplate: DocumentFragment = this.querySelector("template")!.content;
68
+
69
+ /**
70
+ * The underlying Bootstrap modal.
71
+ */
72
+ #modal!: Modal;
73
+
74
+ /**
75
+ * The function invoked to return the dialog box result.
76
+ */
77
+ #resolve: (value: DialogResult) => void = () => { /* Noop */ };
78
+
79
+ /**
80
+ * The dialog result.
81
+ */
82
+ #result: DialogResult = DialogResult.None;
83
+
84
+ /**
85
+ * Creates a new message box.
86
+ */
87
+ constructor() {
88
+ super();
89
+ this.firstElementChild!.addEventListener("hidden.bs.modal", () => this.#resolve(this.#result));
90
+ this.querySelector(".btn-close")!.addEventListener("click", this.#close);
91
+ for (const button of this.querySelectorAll(".modal-footer button")) button.addEventListener("click", this.#close);
92
+ }
93
+
94
+ /**
95
+ * Registers the component.
96
+ */
97
+ static {
98
+ customElements.define("message-box", this);
99
+ }
100
+
101
+ /**
102
+ * Value indicating whether to apply a fade transition.
103
+ */
104
+ get animation(): boolean {
105
+ return this.hasAttribute("animation");
106
+ }
107
+ set animation(value: boolean) {
108
+ this.toggleAttribute("animation", value);
109
+ }
110
+
111
+ /**
112
+ * The child content displayed in the body.
113
+ */
114
+ set body(value: DocumentFragment) { // eslint-disable-line accessor-pairs
115
+ this.querySelector(".modal-body > div")!.replaceChildren(...value.childNodes);
116
+ }
117
+
118
+ /**
119
+ * The title displayed in the header.
120
+ */
121
+ get caption(): string {
122
+ return (this.getAttribute("caption") ?? "").trim();
123
+ }
124
+ set caption(value: string) {
125
+ this.setAttribute("caption", value);
126
+ }
127
+
128
+ /**
129
+ * Value indicating whether to vertically center this message box.
130
+ */
131
+ get centered(): boolean {
132
+ return this.hasAttribute("centered");
133
+ }
134
+ set centered(value: boolean) {
135
+ this.toggleAttribute("centered", value);
136
+ }
137
+
138
+ /**
139
+ * A contextual modifier.
140
+ */
141
+ get context(): Context {
142
+ const value = this.getAttribute("context") as Context;
143
+ return Object.values(Context).includes(value) ? value : Context.Info;
144
+ }
145
+ set context(value: Context) {
146
+ this.setAttribute("context", value);
147
+ }
148
+
149
+ /**
150
+ * The child content displayed in the footer.
151
+ */
152
+ set footer(value: DocumentFragment) { // eslint-disable-line accessor-pairs
153
+ const footer = this.querySelector<HTMLElement>(".modal-footer")!;
154
+ footer.hidden = !value.hasChildNodes();
155
+ footer.replaceChildren(...value.childNodes);
156
+ }
157
+
158
+ /**
159
+ * The icon displayed next to the body.
160
+ */
161
+ get icon(): string|null {
162
+ const value = this.getAttribute("icon") ?? "";
163
+ return value.trim() || null;
164
+ }
165
+ set icon(value: string|null) {
166
+ this.toggleAttribute("icon", Boolean(value));
167
+ }
168
+
169
+ /**
170
+ * Value indicating whether to this message box will not close when clicking outside of it.
171
+ */
172
+ get modal(): boolean {
173
+ return this.hasAttribute("modal");
174
+ }
175
+ set modal(value: boolean) {
176
+ this.toggleAttribute("modal", value);
177
+ }
178
+
179
+ /**
180
+ * Value indicating whether to initially show this message box.
181
+ */
182
+ get open(): boolean {
183
+ return this.hasAttribute("open");
184
+ }
185
+ set open(value: boolean) {
186
+ this.toggleAttribute("open", value);
187
+ }
188
+
189
+ /**
190
+ * Value indicating whether the body is scrollable.
191
+ */
192
+ get scrollable(): boolean {
193
+ return this.hasAttribute("scrollable");
194
+ }
195
+ set scrollable(value: boolean) {
196
+ this.toggleAttribute("scrollable", value);
197
+ }
198
+
199
+ /**
200
+ * Method invoked when an attribute has been changed.
201
+ * @param attribute The attribute name.
202
+ * @param oldValue The previous attribute value.
203
+ * @param newValue The new attribute value.
204
+ */
205
+ attributeChangedCallback(attribute: string, oldValue: string|null, newValue: string|null): void {
206
+ if (newValue != oldValue) switch (attribute) {
207
+ case "animation": this.#updateAnimation(newValue != null); break;
208
+ case "caption": this.#updateCaption(newValue ?? ""); break;
209
+ case "centered": this.#updateCentered(newValue != null); break;
210
+ case "context": this.#updateContext(Object.values(Context).includes(newValue as Context) ? newValue as Context : Context.Info); break;
211
+ case "icon": this.#updateIcon(newValue); break;
212
+ case "modal": this.#updateModal(newValue != null); break;
213
+ case "scrollable": this.#updateScrolling(newValue != null); break;
214
+ // No default
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Shows an alert message with an "OK" button.
220
+ * @param context The contextual modifier.
221
+ * @param caption The title displayed in the header.
222
+ * @param body The child content displayed in the body.
223
+ * @returns The dialog box result.
224
+ */
225
+ async alert(context: Context, caption: string, body: DocumentFragment|string): Promise<DialogResult> {
226
+ return await this.show(context, caption, body, [
227
+ {label: "OK", value: DialogResult.OK, variant: Variant.Primary}
228
+ ]);
229
+ }
230
+
231
+ /**
232
+ * Closes this message box.
233
+ * @param result The dialog box result.
234
+ */
235
+ close(result: DialogResult = DialogResult.None): void {
236
+ this.#result = result;
237
+ this.#modal.hide();
238
+ }
239
+
240
+ /**
241
+ * Shows a confirmation message with two buttons, "OK" and "Cancel".
242
+ * @param context The contextual modifier.
243
+ * @param caption The title displayed in the header.
244
+ * @param body The child content displayed in the body.
245
+ * @returns The dialog box result.
246
+ */
247
+ async confirm(context: Context, caption: string, body: DocumentFragment|string): Promise<DialogResult> {
248
+ return await this.show(context, caption, body, [
249
+ {label: "OK", value: DialogResult.OK, variant: Variant.Primary},
250
+ {label: "Annuler", value: DialogResult.Cancel, variant: Variant.Secondary}
251
+ ]);
252
+ }
253
+
254
+ /**
255
+ * Method invoked when this component is connected.
256
+ */
257
+ connectedCallback(): void {
258
+ this.#modal = new Modal(this.firstElementChild!);
259
+ if (this.open) void this.show();
260
+ }
261
+
262
+ /**
263
+ * Method invoked when this component is disconnected.
264
+ */
265
+ disconnectedCallback(): void {
266
+ this.#modal.dispose();
267
+ }
268
+
269
+ /**
270
+ * Shows a message.
271
+ * @param message The message to show.
272
+ * @returns The dialog box result.
273
+ */
274
+ show(message?: IMessage): Promise<DialogResult>;
275
+
276
+ /**
277
+ * Shows a message.
278
+ * @param context The contextual modifier.
279
+ * @param caption The title displayed in the header.
280
+ * @param body The child content displayed in the body.
281
+ * @param buttons The buttons displayed in the footer.
282
+ * @returns The dialog box result.
283
+ */
284
+ show(context: Context, caption: string, body: DocumentFragment|string, buttons?: IDialogButton[]): Promise<DialogResult>;
285
+
286
+ /**
287
+ * Shows a message.
288
+ * @param message The message to show, or the contextual modifier.
289
+ * @param caption The title displayed in the header.
290
+ * @param body The child content displayed in the body.
291
+ * @param buttons The buttons displayed in the footer.
292
+ * @returns The dialog box result.
293
+ */
294
+ show(message: IMessage|Context|null = null, caption = "", body: DocumentFragment|string = "", buttons: IDialogButton[] = []): Promise<DialogResult> {
295
+ if (typeof message == "string") {
296
+ const footer = document.createDocumentFragment();
297
+ footer.append(...buttons.map(button => this.#createButton(button)));
298
+ message = {context: message, caption, body, footer};
299
+ }
300
+
301
+ if (typeof message == "object" && message) {
302
+ this.body = typeof message.body == "string" ? createDocumentFragment(message.body) : message.body;
303
+ this.caption = message.caption;
304
+ this.context = message.context ?? Context.Info;
305
+ this.icon = message.icon ?? getIcon(this.context);
306
+
307
+ const footer = typeof message.footer == "string" ? createDocumentFragment(message.footer) : (message.footer ?? document.createDocumentFragment());
308
+ for (const button of footer.querySelectorAll("button")) button.addEventListener("click", this.#close);
309
+ this.footer = footer;
310
+ }
311
+
312
+ const {promise, resolve} = Promise.withResolvers<DialogResult>();
313
+ this.#resolve = resolve;
314
+ this.#result = DialogResult.None;
315
+ this.#modal.show();
316
+ return promise;
317
+ }
318
+
319
+ /**
320
+ * Closes this message box.
321
+ * @param event The dispatched event.
322
+ */
323
+ readonly #close: (event: Event) => void = event => {
324
+ const button = (event.target as Element).closest("button")!;
325
+ this.close(Object.values(DialogResult).includes(button.value as DialogResult) ? button.value as DialogResult : DialogResult.None);
326
+ }
327
+
328
+ /**
329
+ * Creates the component instance corresponding to the specified button.
330
+ * @param button An object describing the appearance of the button.
331
+ * @returns The component instance corresponding to the specified button.
332
+ */
333
+ #createButton(button: IDialogButton): DialogButton {
334
+ const element = document.createElement("dialog-button");
335
+ const childContent = (this.#buttonTemplate.cloneNode(true) as DocumentFragment).querySelector("button")!;
336
+ childContent.addEventListener("click", this.#close);
337
+ element.appendChild(childContent);
338
+
339
+ element.context = button.context ?? null;
340
+ element.icon = button.icon ?? null;
341
+ element.label = button.label ?? "";
342
+ element.value = button.value ?? DialogResult.None;
343
+ element.variant = button.variant ?? null;
344
+ return element;
345
+ }
346
+
347
+ /**
348
+ * Updates the message box animation.
349
+ * @param value The new value.
350
+ */
351
+ #updateAnimation(value: boolean): void {
352
+ this.firstElementChild!.classList.toggle("fade", value);
353
+ }
354
+
355
+ /**
356
+ * Updates the title displayed in the header.
357
+ * @param value The new value.
358
+ */
359
+ #updateCaption(value: string): void {
360
+ this.querySelector(".modal-title")!.textContent = value.trim();
361
+ }
362
+
363
+ /**
364
+ * Updates the value indicating whether to vertically center this message box.
365
+ * @param value The new value.
366
+ */
367
+ #updateCentered(value: boolean): void {
368
+ this.querySelector(".modal-dialog")!.classList.toggle("modal-dialog-centered", value);
369
+ }
370
+
371
+ /**
372
+ * Updates the contextual modifier.
373
+ * @param value The new value.
374
+ */
375
+ #updateContext(value: Context): void {
376
+ const {classList} = this.querySelector(".modal-body > .icon")!;
377
+ classList.remove(...Object.values(Context).map(context => `text-${toCss(context)}`));
378
+ classList.add(`text-${toCss(value)}`);
379
+ }
380
+
381
+ /**
382
+ * Updates the icon displayed next to the body.
383
+ * @param value The new value.
384
+ */
385
+ #updateIcon(value: string|null): void {
386
+ this.querySelector(".modal-body > .icon")!.textContent = (value ?? "").trim() || getIcon(this.context);
387
+ }
388
+
389
+ /**
390
+ * Updates the value indicating whether to this message box will not close when clicking outside of it.
391
+ * @param value The new value.
392
+ */
393
+ #updateModal(value: boolean): void {
394
+ (this.firstElementChild! as HTMLElement).dataset.bsBackdrop = value ? "static" : "true";
395
+ }
396
+
397
+ /**
398
+ * Updates the body scrolling.
399
+ * @param value The new value.
400
+ */
401
+ #updateScrolling(value: boolean): void {
402
+ this.querySelector(".modal-dialog")!.classList.toggle("modal-dialog-scrollable", value);
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Declaration merging.
408
+ */
409
+ declare global {
410
+
411
+ /**
412
+ * The map of HTML tag names.
413
+ */
414
+ interface HTMLElementTagNameMap {
415
+ "message-box": MessageBox;
416
+ }
417
+ }
@@ -89,15 +89,9 @@ export class ThemeDropdown extends HTMLElement {
89
89
  */
90
90
  attributeChangedCallback(attribute: string, oldValue: string|null, newValue: string|null): void {
91
91
  if (newValue != oldValue) switch (attribute) {
92
- case "alignment":
93
- this.#updateAlignment(Object.values(Alignment).includes(newValue as Alignment) ? newValue as Alignment : Alignment.End);
94
- break;
95
- case "apptheme":
96
- this.#updateAppTheme(Object.values(AppTheme).includes(newValue as AppTheme) ? newValue as AppTheme : AppTheme.System);
97
- break;
98
- case "label":
99
- this.#updateLabel(newValue ?? "");
100
- break;
92
+ case "alignment": this.#updateAlignment(Object.values(Alignment).includes(newValue as Alignment) ? newValue as Alignment : Alignment.End); break;
93
+ case "apptheme": this.#updateAppTheme(Object.values(AppTheme).includes(newValue as AppTheme) ? newValue as AppTheme : AppTheme.System); break;
94
+ case "label": this.#updateLabel(newValue ?? ""); break;
101
95
  // No default
102
96
  }
103
97
  }
@@ -1,6 +1,52 @@
1
1
  import {Toast as BootstrapToast} from "bootstrap";
2
2
  import {Context, getIcon, toCss} from "../Context.js";
3
3
 
4
+ /**
5
+ * Represents a notification.
6
+ */
7
+ export interface IToast {
8
+
9
+ /**
10
+ * Value indicating whether to apply a fade transition.
11
+ */
12
+ animation?: boolean;
13
+
14
+ /**
15
+ * Value indicating whether to automatically hide the toast.
16
+ */
17
+ autoHide?: boolean;
18
+
19
+ /**
20
+ * The child content displayed in the body.
21
+ */
22
+ body: DocumentFragment|string;
23
+
24
+ /**
25
+ * The title displayed in the header.
26
+ */
27
+ caption: string;
28
+
29
+ /**
30
+ * The default contextual modifier.
31
+ */
32
+ context?: Context;
33
+
34
+ /**
35
+ * The culture used to format the relative time.
36
+ */
37
+ culture?: Intl.Locale;
38
+
39
+ /**
40
+ * The delay, in milliseconds, to hide the toast.
41
+ */
42
+ delay?: number;
43
+
44
+ /**
45
+ * The icon displayed next to the caption.
46
+ */
47
+ icon?: string|null;
48
+ }
49
+
4
50
  /**
5
51
  * Represents a notification.
6
52
  */
@@ -9,7 +55,7 @@ export class Toast extends HTMLElement {
9
55
  /**
10
56
  * The list of observed attributes.
11
57
  */
12
- static readonly observedAttributes = ["animation", "caption", "context", "culture", "icon", "open"];
58
+ static readonly observedAttributes = ["animation", "autohide", "caption", "context", "culture", "delay", "icon"];
13
59
 
14
60
  /**
15
61
  * The time units.
@@ -50,8 +96,7 @@ export class Toast extends HTMLElement {
50
96
  return this.hasAttribute("animation");
51
97
  }
52
98
  set animation(value: boolean) {
53
- if (value) this.setAttribute("animation", "");
54
- else this.removeAttribute("animation");
99
+ this.toggleAttribute("animation", value);
55
100
  }
56
101
 
57
102
  /**
@@ -61,8 +106,14 @@ export class Toast extends HTMLElement {
61
106
  return this.hasAttribute("autohide");
62
107
  }
63
108
  set autoHide(value: boolean) {
64
- if (value) this.setAttribute("autohide", "");
65
- else this.removeAttribute("autohide");
109
+ this.toggleAttribute("autohide", value);
110
+ }
111
+
112
+ /**
113
+ * The child content displayed in the body.
114
+ */
115
+ set body(value: DocumentFragment) { // eslint-disable-line accessor-pairs
116
+ this.querySelector(".toast-body")!.replaceChildren(...value.childNodes);
66
117
  }
67
118
 
68
119
  /**
@@ -75,13 +126,6 @@ export class Toast extends HTMLElement {
75
126
  this.setAttribute("caption", value);
76
127
  }
77
128
 
78
- /**
79
- * The child content displayed in the body.
80
- */
81
- set childContent(value: DocumentFragment) { // eslint-disable-line accessor-pairs
82
- this.querySelector(".toast-body")!.replaceChildren(...value.childNodes);
83
- }
84
-
85
129
  /**
86
130
  * A contextual modifier.
87
131
  */
@@ -141,8 +185,7 @@ export class Toast extends HTMLElement {
141
185
  return this.hasAttribute("open");
142
186
  }
143
187
  set open(value: boolean) {
144
- if (value) this.setAttribute("open", "");
145
- else this.removeAttribute("open");
188
+ this.toggleAttribute("open", value);
146
189
  }
147
190
 
148
191
  /**
@@ -154,25 +197,33 @@ export class Toast extends HTMLElement {
154
197
  attributeChangedCallback(attribute: string, oldValue: string|null, newValue: string|null): void {
155
198
  if (newValue != oldValue) switch (attribute) {
156
199
  case "animation": this.#updateAnimation(newValue != null); break;
200
+ case "autohide": this.#updateAutoHide(newValue != null); break;
157
201
  case "caption": this.#updateCaption(newValue ?? ""); break;
158
202
  case "context": this.#updateContext(Object.values(Context).includes(newValue as Context) ? newValue as Context : Context.Info); break;
159
203
  case "culture": this.#formatter = new Intl.RelativeTimeFormat((newValue ?? "").trim() || navigator.language, {style: "long"}); break;
204
+ case "delay": this.#updateDelay(Number(newValue)); break;
160
205
  case "icon": this.#updateIcon(newValue); break;
161
- case "open": this.#updateVisibility(newValue != null); break;
162
206
  // No default
163
207
  }
164
208
  }
165
209
 
210
+ /**
211
+ * Closes this toast.
212
+ */
213
+ close(): void {
214
+ this.#toast.hide();
215
+ }
216
+
166
217
  /**
167
218
  * Method invoked when this component is connected.
168
219
  */
169
220
  connectedCallback(): void {
170
- const toast = this.querySelector(".toast")!;
221
+ const toast = this.firstElementChild!;
171
222
  toast.addEventListener("hidden.bs.toast", () => clearInterval(this.#timer));
172
223
  toast.addEventListener("show.bs.toast", () => this.#timer = window.setInterval(this.#updateElapsedTime, 1_000));
173
224
 
174
- const {animation, autoHide: autohide, delay} = this;
175
- this.#toast = new BootstrapToast(toast, {animation, autohide, delay});
225
+ this.#toast = new BootstrapToast(toast);
226
+ if (this.open) this.show();
176
227
  }
177
228
 
178
229
  /**
@@ -183,13 +234,6 @@ export class Toast extends HTMLElement {
183
234
  this.#toast.dispose();
184
235
  }
185
236
 
186
- /**
187
- * Hides this toast.
188
- */
189
- hide(): void {
190
- this.#toast.hide();
191
- }
192
-
193
237
  /**
194
238
  * Shows this toast.
195
239
  */
@@ -218,11 +262,19 @@ export class Toast extends HTMLElement {
218
262
  }
219
263
 
220
264
  /**
221
- * Updates the toast animation.
265
+ * Updates the value indicating whether to apply a fade transition.
222
266
  * @param value The new value.
223
267
  */
224
268
  #updateAnimation(value: boolean): void {
225
- this.firstElementChild!.classList.toggle("fade", value);
269
+ (this.firstElementChild! as HTMLElement).dataset.bsAnimation = value ? "true" : "false";
270
+ }
271
+
272
+ /**
273
+ * Updates the value indicating whether to automatically hide this toast.
274
+ * @param value The new value.
275
+ */
276
+ #updateAutoHide(value: boolean): void {
277
+ (this.firstElementChild! as HTMLElement).dataset.bsAutohide = value ? "true" : "false";
226
278
  }
227
279
 
228
280
  /**
@@ -249,6 +301,15 @@ export class Toast extends HTMLElement {
249
301
  classList.add(`text-${toCss(value)}`);
250
302
  }
251
303
 
304
+ /**
305
+ * Updates the delay to hide the toast.
306
+ * @param value The new value.
307
+ */
308
+ #updateDelay(value: number): void {
309
+ const delay = Math.max(0, Number.isNaN(value) ? 5_000 : value);
310
+ (this.firstElementChild! as HTMLElement).dataset.bsDelay = delay.toString();
311
+ }
312
+
252
313
  /**
253
314
  * Updates the label corresponding to the elapsed time.
254
315
  */
@@ -264,14 +325,6 @@ export class Toast extends HTMLElement {
264
325
  #updateIcon(value: string|null): void {
265
326
  this.querySelector(".toast-header .icon")!.textContent = (value ?? "").trim() || getIcon(this.context);
266
327
  }
267
-
268
- /**
269
- * Updates the toast visibility.
270
- * @param value The new value.
271
- */
272
- #updateVisibility(value: boolean): void {
273
- this.firstElementChild!.classList.toggle("show", value);
274
- }
275
328
  }
276
329
 
277
330
  /**