@cedx/base 0.11.0 → 0.13.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,252 @@
1
+ import {Duration} from "@cedx/base/Duration.js";
2
+ import {Context, getIcon, toCss} from "@cedx/base/UI/Context.js";
3
+ import {Toast as BootstrapToast} from "bootstrap";
4
+
5
+ /**
6
+ * Manages the notification messages.
7
+ */
8
+ export class Toast extends HTMLElement {
9
+
10
+ /**
11
+ * The list of observed attributes.
12
+ */
13
+ static readonly observedAttributes = ["caption", "context", "culture", "icon"];
14
+
15
+ /**
16
+ * The time units.
17
+ */
18
+ static readonly #timeUnits: Intl.RelativeTimeFormatUnit[] = ["second", "minute", "hour"];
19
+
20
+ /**
21
+ * The formatter used to format the relative time.
22
+ */
23
+ #formatter!: Intl.RelativeTimeFormat;
24
+
25
+ /**
26
+ * The toast header.
27
+ */
28
+ readonly #header = this.querySelector(".toast-header")!;
29
+
30
+ /**
31
+ * The time at which this component was initially shown.
32
+ */
33
+ #initialTime = Date.now();
34
+
35
+ /**
36
+ * The timer identifier.
37
+ */
38
+ #timer = 0;
39
+
40
+ /**
41
+ * The underlying Bootstrap toast.
42
+ */
43
+ #toast!: BootstrapToast;
44
+
45
+ /**
46
+ * Registers the component.
47
+ */
48
+ static {
49
+ customElements.define("toaster-item", this);
50
+ }
51
+
52
+ /**
53
+ * Value indicating whether to apply a fade transition.
54
+ */
55
+ get animation(): boolean {
56
+ return this.hasAttribute("animation");
57
+ }
58
+ set animation(value: boolean) {
59
+ if (value) this.setAttribute("animation", "");
60
+ else this.removeAttribute("animation");
61
+ }
62
+
63
+ /**
64
+ * Value indicating whether to automatically hide the notification.
65
+ */
66
+ get autoHide(): boolean {
67
+ return this.hasAttribute("autohide");
68
+ }
69
+ set autoHide(value: boolean) {
70
+ if (value) this.setAttribute("autohide", "");
71
+ else this.removeAttribute("autohide");
72
+ }
73
+
74
+ /**
75
+ * The title displayed in the header.
76
+ */
77
+ get caption(): string {
78
+ return (this.getAttribute("caption") ?? "").trim();
79
+ }
80
+ set caption(value: string) {
81
+ this.setAttribute("caption", value);
82
+ }
83
+
84
+ /**
85
+ * A contextual modifier.
86
+ */
87
+ get context(): Context {
88
+ const value = this.getAttribute("context") as Context;
89
+ return Object.values(Context).includes(value) ? value : Context.Info;
90
+ }
91
+ set context(value: Context) {
92
+ this.setAttribute("context", value);
93
+ }
94
+
95
+ /**
96
+ * The culture used to format the relative time.
97
+ */
98
+ get culture(): Intl.Locale {
99
+ const value = this.getAttribute("culture") ?? "";
100
+ return new Intl.Locale(value.trim() || navigator.language);
101
+ }
102
+ set culture(value: Intl.Locale) {
103
+ this.setAttribute("culture", value.toString());
104
+ }
105
+
106
+ /**
107
+ * The delay, in milliseconds, to hide the notification.
108
+ */
109
+ get delay(): number {
110
+ const value = Number(this.getAttribute("delay"));
111
+ return Math.max(1, Number.isNaN(value) ? 5_000 : value);
112
+ }
113
+ set delay(value: number) {
114
+ this.setAttribute("delay", value.toString());
115
+ }
116
+
117
+ /**
118
+ * The time elapsed since this component was initially shown, in milliseconds.
119
+ */
120
+ get elapsedTime(): number {
121
+ return Date.now() - this.#initialTime;
122
+ }
123
+
124
+ /**
125
+ * The icon displayed next to the caption.
126
+ */
127
+ get icon(): string {
128
+ const value = this.getAttribute("icon") ?? "";
129
+ return value.trim() || getIcon(Context.Info);
130
+ }
131
+ set icon(value: string) {
132
+ this.setAttribute("icon", value);
133
+ }
134
+
135
+ /**
136
+ * Method invoked when an attribute has been changed.
137
+ * @param attribute The attribute name.
138
+ * @param oldValue The previous attribute value.
139
+ * @param newValue The new attribute value.
140
+ */
141
+ attributeChangedCallback(attribute: string, oldValue: string|null, newValue: string|null): void {
142
+ if (newValue != oldValue) switch (attribute) {
143
+ case "caption":
144
+ this.#updateCaption(newValue ?? "");
145
+ break;
146
+ case "context":
147
+ this.#updateContext(Object.values(Context).includes(newValue as Context) ? newValue as Context : Context.Info);
148
+ break;
149
+ case "culture":
150
+ this.#formatter = new Intl.RelativeTimeFormat((newValue ?? "").trim() || navigator.language, {style: "long"});
151
+ break;
152
+ case "icon":
153
+ this.#updateIcon(newValue ?? "")
154
+ break;
155
+ // No default
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Method invoked when this component is connected.
161
+ */
162
+ connectedCallback(): void {
163
+ const toast = this.querySelector(".toast")!;
164
+ toast.addEventListener("hidden.bs.toast", () => clearInterval(this.#timer));
165
+ toast.addEventListener("show.bs.toast", () => this.#timer = window.setInterval(this.#updateElapsedTime, Duration.Second));
166
+
167
+ const {animation, autoHide: autohide, delay} = this;
168
+ this.#toast = new BootstrapToast(toast, {animation, autohide, delay});
169
+ }
170
+
171
+ /**
172
+ * Method invoked when this component is disconnected.
173
+ */
174
+ disconnectedCallback(): void {
175
+ clearInterval(this.#timer);
176
+ this.#toast.dispose();
177
+ }
178
+
179
+ /**
180
+ * Hides this toast.
181
+ */
182
+ hide(): void {
183
+ this.#toast.hide();
184
+ }
185
+
186
+ /**
187
+ * Shows this toast.
188
+ */
189
+ show(): void {
190
+ if (!this.#toast.isShown()) {
191
+ this.#initialTime = Date.now();
192
+ this.#updateElapsedTime();
193
+ }
194
+
195
+ this.#toast.show();
196
+ }
197
+
198
+ /**
199
+ * Formats the specified elapsed time.
200
+ * @param elapsed The elapsed time, in seconds.
201
+ * @returns The formated time.
202
+ */
203
+ #formatTime(elapsed: number): string {
204
+ let index = 0;
205
+ while (elapsed > 60 && index < Toast.#timeUnits.length) {
206
+ elapsed /= 60;
207
+ index++;
208
+ }
209
+
210
+ return this.#formatter.format(Math.ceil(-elapsed), Toast.#timeUnits[index]);
211
+ }
212
+
213
+ /**
214
+ * Updates the title displayed in the header.
215
+ * @param value The new value.
216
+ */
217
+ #updateCaption(value: string): void {
218
+ this.#header.querySelector("b")!.textContent = value.trim();
219
+ }
220
+
221
+ /**
222
+ * Updates the title displayed in the header.
223
+ * @param value The new value.
224
+ */
225
+ #updateContext(value: Context): void {
226
+ const contexts = Object.values(Context);
227
+
228
+ let {classList} = this.#header;
229
+ classList.remove(...contexts.map(context => `toast-header-${toCss(context)}`));
230
+ classList.add(`toast-header-${value}`);
231
+
232
+ ({classList} = this.#header.querySelector(".icon")!);
233
+ classList.remove(...contexts.map(context => `text-${toCss(context)}`));
234
+ classList.add(`text-${value}`);
235
+ }
236
+
237
+ /**
238
+ * Updates the label corresponding to the elapsed time.
239
+ */
240
+ readonly #updateElapsedTime: () => void = () => {
241
+ const {elapsedTime} = this;
242
+ this.#header.querySelector("small")!.textContent = elapsedTime > 0 ? this.#formatTime(elapsedTime / Duration.Second) : "";
243
+ };
244
+
245
+ /**
246
+ * Updates the icon displayed next to the caption.
247
+ * @param value The new value.
248
+ */
249
+ #updateIcon(value: string): void {
250
+ this.#header.querySelector(".icon")!.textContent = value.trim() || getIcon(Context.Info);
251
+ }
252
+ }
@@ -6,22 +6,22 @@ export const Context = Object.freeze({
6
6
  /**
7
7
  * A danger.
8
8
  */
9
- Danger: "danger",
9
+ Danger: "Danger",
10
10
 
11
11
  /**
12
12
  * A warning.
13
13
  */
14
- Warning: "warning",
14
+ Warning: "Warning",
15
15
 
16
16
  /**
17
17
  * An information.
18
18
  */
19
- Info: "info",
19
+ Info: "Info",
20
20
 
21
21
  /**
22
22
  * A success.
23
23
  */
24
- Success: "success"
24
+ Success: "Success"
25
25
  });
26
26
 
27
27
  /**
@@ -42,3 +42,12 @@ export function getIcon(context: Context): string {
42
42
  default: return "info";
43
43
  }
44
44
  }
45
+
46
+ /**
47
+ * Returns the CSS representation of the specified context.
48
+ * @param context The context.
49
+ * @returns The CSS representation of the specified context.
50
+ */
51
+ export function toCss(context: Context): string {
52
+ return context.toLowerCase();
53
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Defines the position of an element.
3
+ */
4
+ export const Position = Object.freeze({
5
+
6
+ /**
7
+ * Top left.
8
+ */
9
+ TopStart: "TopStart",
10
+
11
+ /**
12
+ * Top center.
13
+ */
14
+ TopCenter: "TopCenter",
15
+
16
+ /**
17
+ * Top right.
18
+ */
19
+ TopEnd: "TopEnd",
20
+
21
+ /**
22
+ * Middle left.
23
+ */
24
+ MiddleStart: "MiddleStart",
25
+
26
+ /**
27
+ * Middle center.
28
+ */
29
+ MiddleCenter: "MiddleCenter",
30
+
31
+ /**
32
+ * Middle right.
33
+ */
34
+ MiddleEnd: "MiddleEnd",
35
+
36
+ /**
37
+ * Bottom left.
38
+ */
39
+ BottomStart: "BottomStart",
40
+
41
+ /**
42
+ * Bottom center.
43
+ */
44
+ BottomCenter: "BottomCenter",
45
+
46
+ /**
47
+ * Bottom right.
48
+ */
49
+ BottomEnd: "BottomEnd"
50
+ });
51
+
52
+ /**
53
+ * Defines the placement of an element.
54
+ */
55
+ export type Position = typeof Position[keyof typeof Position];
56
+
57
+ /**
58
+ * Returns the CSS representation of the specified position.
59
+ * @param position The position.
60
+ * @returns The CSS representation of the specified position.
61
+ */
62
+ export function toCss(position: Position): string {
63
+ switch (position) {
64
+ case Position.TopCenter: return "top-0 start-50 translate-middle-x";
65
+ case Position.TopEnd: return "top-0 end-0";
66
+ case Position.MiddleStart: return "top-50 start-0 translate-middle-y";
67
+ case Position.MiddleCenter: return "top-50 start-50 translate-middle";
68
+ case Position.MiddleEnd: return "top-50 end-0 translate-middle-y";
69
+ case Position.BottomStart: return "bottom-0 start-0";
70
+ case Position.BottomCenter: return "bottom-0 start-50 translate-middle-x";
71
+ case Position.BottomEnd: return "bottom-0 end-0";
72
+ default: return "top-0 start-0";
73
+ }
74
+ }
@@ -1,40 +1,56 @@
1
1
  /**
2
- * Defines the size of a component.
2
+ * Defines the size of an element.
3
3
  */
4
4
  export const Size = Object.freeze({
5
5
 
6
6
  /**
7
7
  * An extra small size.
8
8
  */
9
- ExtraSmall: "xs",
9
+ ExtraSmall: "ExtraSmall",
10
10
 
11
11
  /**
12
12
  * A small size.
13
13
  */
14
- Small: "sm",
14
+ Small: "Small",
15
15
 
16
16
  /**
17
17
  * A medium size.
18
18
  */
19
- Medium: "md",
19
+ Medium: "Medium",
20
20
 
21
21
  /**
22
22
  * A large size.
23
23
  */
24
- Large: "lg",
24
+ Large: "Large",
25
25
 
26
26
  /**
27
27
  * An extra large size.
28
28
  */
29
- ExtraLarge: "xl",
29
+ ExtraLarge: "ExtraLarge",
30
30
 
31
31
  /**
32
32
  * An extra extra large size.
33
33
  */
34
- ExtraExtraLarge: "xxl"
34
+ ExtraExtraLarge: "ExtraExtraLarge"
35
35
  });
36
36
 
37
37
  /**
38
- * Defines the size of a component.
38
+ * Defines the size of an element.
39
39
  */
40
40
  export type Size = typeof Size[keyof typeof Size];
41
+
42
+ /**
43
+ * Returns the CSS representation of the specified size.
44
+ * @param size The size.
45
+ * @returns The CSS representation of the specified size.
46
+ */
47
+ export function toCss(size: Size): string {
48
+ switch (size) {
49
+ case Size.ExtraSmall: return "xs";
50
+ case Size.Small: return "sm";
51
+ case Size.Large: return "lg";
52
+ case Size.ExtraLarge: return "xl";
53
+ case Size.ExtraExtraLarge: return "xxl";
54
+ default: return "md";
55
+ }
56
+ }
@@ -6,25 +6,34 @@ export const Variant = Object.freeze({
6
6
  /**
7
7
  * A dark variant.
8
8
  */
9
- Dark: "dark",
9
+ Dark: "Dark",
10
10
 
11
11
  /**
12
12
  * A light variant.
13
13
  */
14
- Light: "light",
14
+ Light: "Light",
15
15
 
16
16
  /**
17
17
  * A primary variant.
18
18
  */
19
- Primary: "primary",
19
+ Primary: "Primary",
20
20
 
21
21
  /**
22
22
  * A secondary variant.
23
23
  */
24
- Secondary: "secondary"
24
+ Secondary: "Secondary"
25
25
  });
26
26
 
27
27
  /**
28
28
  * Defines tone variants.
29
29
  */
30
30
  export type Variant = typeof Variant[keyof typeof Variant];
31
+
32
+ /**
33
+ * Returns the CSS representation of the specified variant.
34
+ * @param variant The variant.
35
+ * @returns The CSS representation of the specified variant.
36
+ */
37
+ export function toCss(variant: Variant): string {
38
+ return variant.toLowerCase();
39
+ }
@@ -1,242 +0,0 @@
1
- import {Context, getIcon} from "../Context.js";
2
- import {Variant} from "../Variant.js";
3
-
4
- /**
5
- * Specifies the return value of a message box.
6
- */
7
- export const MessageBoxResult = Object.freeze({
8
-
9
- /**
10
- * The message box does not return any value.
11
- */
12
- None: "",
13
-
14
- /**
15
- * The return value of the message box is "OK".
16
- */
17
- OK: "OK",
18
-
19
- /**
20
- * The return value of the message box is "Cancel".
21
- */
22
- Cancel: "Cancel"
23
- });
24
-
25
- /**
26
- * Specifies the return value of a message box.
27
- */
28
- export type MessageBoxResult = typeof MessageBoxResult[keyof typeof MessageBoxResult];
29
-
30
- /**
31
- * Displays a message window, also known as dialog box, which presents a message to the user.
32
- */
33
- export class MessageBox extends HTMLElement {
34
-
35
- /**
36
- * Value indicating whether to vertically center this message box.
37
- */
38
- @property({type: Boolean}) centered = false;
39
-
40
- /**
41
- * The buttons displayed in the footer.
42
- */
43
- @state() private buttons: MessageBoxButton[] = [];
44
-
45
- /**
46
- * The title displayed in the header.
47
- */
48
- @state() private caption = "";
49
-
50
- /**
51
- * The message displayed in the body.
52
- */
53
- @state() private content: TemplateResult = html``;
54
-
55
- /**
56
- * A contextual modifier.
57
- */
58
- @state() private context: Context|null = null;
59
-
60
- /**
61
- * The icon displayed next to the body.
62
- */
63
- @state() private icon = "";
64
-
65
- /**
66
- * The root element.
67
- */
68
- @query("dialog", true) private readonly root!: HTMLDialogElement;
69
-
70
- /**
71
- * The function invoked to return the dialog box result.
72
- */
73
- #resolve: (value: string) => void = () => { /* Noop */ };
74
-
75
- /**
76
- * Opens an alert dialog with the specified message and an "OK" button.
77
- * @param caption The title displayed in the header.
78
- * @param message The message to show.
79
- * @param options The message box options.
80
- * @returns Resolves with the value of the clicked button.
81
- */
82
- alert(caption: string, message: TemplateResult, options: MessageBoxOptions = {}): Promise<string> {
83
- const context = options.context ?? Context.Warning;
84
- return this.show(message, {
85
- buttons: options.buttons ?? [{label: "OK", value: MessageBoxResult.OK}],
86
- caption,
87
- context,
88
- icon: options.icon ?? getIcon(context)
89
- });
90
- }
91
-
92
- /**
93
- * Closes the message box.
94
- * @param result The message box result.
95
- */
96
- close(result: MessageBoxResult = MessageBoxResult.None): void {
97
- this.root.close(result);
98
- }
99
-
100
- /**
101
- * Opens a confirmation dialog with the specified message and two buttons, "OK" and "Cancel".
102
- * @param caption The title displayed in the header.
103
- * @param message The message to show.
104
- * @param options The message box options.
105
- * @returns Resolves with the value of the clicked button.
106
- */
107
- confirm(caption: string, message: TemplateResult, options: MessageBoxOptions = {}): Promise<string> {
108
- const context = options.context ?? Context.Warning;
109
- return this.show(message, {
110
- caption,
111
- context,
112
- icon: options.icon ?? getIcon(context),
113
- buttons: options.buttons ?? [
114
- {label: "OK", value: MessageBoxResult.OK},
115
- {label: "Annuler", value: MessageBoxResult.Cancel, variant: Variant.Secondary}
116
- ]
117
- });
118
- }
119
-
120
- /**
121
- * Opens a modal dialog with the specified message and options.
122
- * @param message The message to show.
123
- * @param options The message box options.
124
- * @returns Resolves with the value of the clicked button.
125
- */
126
- show(message: TemplateResult, options: MessageBoxOptions = {}): Promise<string> {
127
- this.buttons = options.buttons ?? [];
128
- this.caption = options.caption ?? "";
129
- this.content = message;
130
- this.context = options.context ?? null;
131
- this.icon = options.icon ?? "";
132
-
133
- this.root.returnValue = MessageBoxResult.None;
134
- this.root.showModal();
135
- this.root.querySelector<HTMLButtonElement>(".btn-close")?.blur();
136
-
137
- const {promise, resolve} = Promise.withResolvers<string>();
138
- this.#resolve = resolve;
139
- return promise;
140
- }
141
-
142
- /**
143
- * Renders this component.
144
- * @returns The view template.
145
- */
146
- protected override render(): TemplateResult {
147
- const centered = classMap({"modal-dialog-centered": this.centered});
148
- return html`
149
- <dialog class="modal modal-dialog modal-dialog-scrollable ${centered}" @click=${this.#onDialogClick} @close=${this.#onDialogClose}>
150
- <form class="modal-content" method="dialog">
151
- <div class="modal-header user-select-none">
152
- ${when(this.caption, () => html`<h1 class="modal-title fs-5">${this.caption}</h1>`)}
153
- <button class="btn-close"></button>
154
- </div>
155
- <div class="modal-body d-flex">
156
- ${when(this.icon, () => html`
157
- <i class="icon icon-fill fs-1 fw-semibold me-2 ${classMap({[`text-${this.context}`]: this.context ?? ""})}">${this.icon}</i>
158
- `)}
159
- <div class="flex-grow-1">${this.content}</div>
160
- </div>
161
- ${when(this.buttons.length, () => html`
162
- <div class="modal-footer user-select-none">
163
- ${this.buttons.map(button => html`
164
- <button class="btn btn-${button.variant ?? Variant.Primary}" value=${button.value ?? MessageBoxResult.None}>
165
- ${when(button.icon, () => html`<i class="icon icon-fill ${classMap({"me-1": button.label ?? ""})}">${button.icon}</i>`)}
166
- ${button.label}
167
- </button>
168
- `)}
169
- </div>
170
- `)}
171
- </form>
172
- </dialog>
173
- `;
174
- }
175
-
176
- /**
177
- * Handles the `click` event.
178
- * @param event The dispatched event.
179
- */
180
- #onDialogClick(event: Event): void {
181
- if (event.target == this.root) this.close();
182
- }
183
-
184
- /**
185
- * Handles the `close` event.
186
- */
187
- #onDialogClose(): void {
188
- this.#resolve(this.root.returnValue);
189
- }
190
- }
191
-
192
- /**
193
- * A message box button.
194
- */
195
- export type MessageBoxButton = Partial<{
196
-
197
- /**
198
- * The button icon.
199
- */
200
- icon: string;
201
-
202
- /**
203
- * The button label.
204
- */
205
- label: string;
206
-
207
- /**
208
- * The button value.
209
- */
210
- value: string;
211
-
212
- /**
213
- * A tone variant.
214
- */
215
- variant: Context|Variant;
216
- }>;
217
-
218
- /**
219
- * Defines the options of a {@link MessageBox} instance.
220
- */
221
- export type MessageBoxOptions = Partial<{
222
-
223
- /**
224
- * The buttons displayed in the footer.
225
- */
226
- buttons: MessageBoxButton[];
227
-
228
- /**
229
- * The title displayed in the header.
230
- */
231
- caption: string;
232
-
233
- /**
234
- * A contextual modifier.
235
- */
236
- context: Context;
237
-
238
- /**
239
- * The icon displayed next to the body.
240
- */
241
- icon: string;
242
- }>;
File without changes