@rettangoli/ui 1.4.1 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/ui",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "A UI component library for building web interfaces.",
5
5
  "main": "dist/rettangoli-esm.min.js",
6
6
  "type": "module",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "homepage": "https://github.com/yuusoft-org/rettangoli#readme",
64
64
  "dependencies": {
65
- "@rettangoli/fe": "1.1.2",
65
+ "@rettangoli/fe": "1.1.3",
66
66
  "jempl": "1.0.1"
67
67
  }
68
68
  }
@@ -22,7 +22,7 @@ template:
22
22
  - $for item, i in items:
23
23
  - $if item.isLabel:
24
24
  - rtgl-view w=f p=md:
25
- - rtgl-text s=sm c=mu-fg: ${item.label}
25
+ - rtgl-text s=xs c=mu-fg: ${item.label}
26
26
  $elif item.isItem && item.isDisabled:
27
27
  - rtgl-view w=f p=md bgc=${item.bgc}:
28
28
  - rtgl-view d=h av=c w=f g=md:
@@ -34,7 +34,7 @@ template:
34
34
  - div class=icon-placeholder aria-hidden="true": null
35
35
  - rtgl-text s=sm c=${item.c} w=1fg ellipsis: ${item.label}
36
36
  - $if item.hasSuffixText:
37
- - rtgl-text s=sm c=${item.suffixTextColor} ta=e: ${item.suffixText}
37
+ - rtgl-text s=xs c=${item.suffixTextColor} ta=e: ${item.suffixText}
38
38
  $elif item.isItem && item.hasHref && item.linkExtraAttrs:
39
39
  - a#option${i} href=${item.href} ${item.linkExtraAttrs} data-index=${item.index} data-testid=${item.testId}:
40
40
  - rtgl-view w=f h-bgc=${item.hoverBgc} p=md cur=pointer bgc=${item.bgc}:
@@ -47,7 +47,7 @@ template:
47
47
  - div class=icon-placeholder aria-hidden="true": null
48
48
  - rtgl-text s=sm c=${item.c} w=1fg ellipsis: ${item.label}
49
49
  - $if item.hasSuffixText:
50
- - rtgl-text s=sm c=${item.suffixTextColor} ta=e: ${item.suffixText}
50
+ - rtgl-text s=xs c=${item.suffixTextColor} ta=e: ${item.suffixText}
51
51
  $elif item.isItem && item.hasHref:
52
52
  - a#option${i} href=${item.href} data-index=${item.index} data-testid=${item.testId}:
53
53
  - rtgl-view w=f h-bgc=${item.hoverBgc} p=md cur=pointer bgc=${item.bgc}:
@@ -60,7 +60,7 @@ template:
60
60
  - div class=icon-placeholder aria-hidden="true": null
61
61
  - rtgl-text s=sm c=${item.c} w=1fg ellipsis: ${item.label}
62
62
  - $if item.hasSuffixText:
63
- - rtgl-text s=sm c=${item.suffixTextColor} ta=e: ${item.suffixText}
63
+ - rtgl-text s=xs c=${item.suffixTextColor} ta=e: ${item.suffixText}
64
64
  $elif item.isItem:
65
65
  - rtgl-view#option${i} w=f h-bgc=${item.hoverBgc} p=md cur=pointer bgc=${item.bgc} data-index=${item.index} data-testid=${item.testId}:
66
66
  - rtgl-view d=h av=c w=f g=md:
@@ -72,6 +72,6 @@ template:
72
72
  - div class=icon-placeholder aria-hidden="true": null
73
73
  - rtgl-text s=sm c=${item.c} w=1fg ellipsis: ${item.label}
74
74
  - $if item.hasSuffixText:
75
- - rtgl-text s=sm c=${item.suffixTextColor} ta=e: ${item.suffixText}
75
+ - rtgl-text s=xs c=${item.suffixTextColor} ta=e: ${item.suffixText}
76
76
  $elif item.isSeparator:
77
77
  - rtgl-view w=f h=1 bgc=mu mv=sm: null
@@ -1,3 +1,7 @@
1
+ const TOAST_DURATION_MS = 3000;
2
+ const TOAST_EXIT_DURATION_MS = 180;
3
+ const toastTimeoutsByGlobalUI = new WeakMap();
4
+
1
5
  const clearComponentDialogBody = (refs) => {
2
6
  if (!refs) {
3
7
  return;
@@ -74,6 +78,64 @@ const createUiPromise = (globalUI, { rejectOnError = false } = {}) => {
74
78
  });
75
79
  };
76
80
 
81
+ const getToastTimeouts = (globalUI) => {
82
+ let timeouts = toastTimeoutsByGlobalUI.get(globalUI);
83
+
84
+ if (!timeouts) {
85
+ timeouts = new Map();
86
+ toastTimeoutsByGlobalUI.set(globalUI, timeouts);
87
+ }
88
+
89
+ return timeouts;
90
+ };
91
+
92
+ const clearToastTimeout = (globalUI, toastId) => {
93
+ const timeouts = toastTimeoutsByGlobalUI.get(globalUI);
94
+ const timeoutIds = timeouts?.get(toastId);
95
+
96
+ if (timeoutIds) {
97
+ clearTimeout(timeoutIds.exitTimerId);
98
+ clearTimeout(timeoutIds.removeTimerId);
99
+ timeouts.delete(toastId);
100
+ }
101
+ };
102
+
103
+ const clearAllToastTimeouts = (globalUI) => {
104
+ const timeouts = toastTimeoutsByGlobalUI.get(globalUI);
105
+
106
+ if (!timeouts) {
107
+ return;
108
+ }
109
+
110
+ for (const timeoutIds of timeouts.values()) {
111
+ clearTimeout(timeoutIds.exitTimerId);
112
+ clearTimeout(timeoutIds.removeTimerId);
113
+ }
114
+
115
+ timeouts.clear();
116
+ };
117
+
118
+ const scheduleToastRemoval = ({ store, render, globalUI }, toastId) => {
119
+ clearToastTimeout(globalUI, toastId);
120
+
121
+ const timeouts = getToastTimeouts(globalUI);
122
+ const exitTimerId = setTimeout(() => {
123
+ store.setToastPhase?.({ id: toastId, phase: "exiting" });
124
+ render();
125
+ }, Math.max(0, TOAST_DURATION_MS - TOAST_EXIT_DURATION_MS));
126
+
127
+ const removeTimerId = setTimeout(() => {
128
+ timeouts.delete(toastId);
129
+ store.removeToast({ id: toastId });
130
+ render();
131
+ }, TOAST_DURATION_MS);
132
+
133
+ timeouts.set(toastId, {
134
+ exitTimerId,
135
+ removeTimerId,
136
+ });
137
+ };
138
+
77
139
  const scheduleFormDialogMount = (deps, expectedKey) => {
78
140
  setTimeout(() => {
79
141
  const { store, refs } = deps;
@@ -218,6 +280,21 @@ export const handleShowConfirm = async (deps, payload) => {
218
280
  return createUiPromise(globalUI);
219
281
  };
220
282
 
283
+ export const handleShowToast = (deps, payload) => {
284
+ const { store, render, globalUI } = deps;
285
+ store.addToast(payload);
286
+ render();
287
+
288
+ const toasts = store.selectToasts?.() ?? [];
289
+ const toastId = toasts[toasts.length - 1]?.id;
290
+
291
+ if (!toastId) {
292
+ return;
293
+ }
294
+
295
+ scheduleToastRemoval({ store, render, globalUI }, toastId);
296
+ };
297
+
221
298
  /**
222
299
  * Shows a dropdown menu at the specified position with the given items.
223
300
  * Closes any existing dialog or dropdown menu before showing the dropdown menu.
@@ -322,7 +399,21 @@ export const handleFormFieldEvent = (deps, payload) => {
322
399
  */
323
400
  export const handleCloseAll = (deps) => {
324
401
  const { store, render, globalUI, refs } = deps;
325
- closeCurrentUi({ store, render, globalUI, refs, emitResult: true });
402
+ clearAllToastTimeouts(globalUI);
403
+ const hasToasts = (store.selectToasts?.().length ?? 0) > 0;
404
+
405
+ if (hasToasts) {
406
+ store.clearToasts();
407
+ }
408
+
409
+ if (store.selectIsOpen()) {
410
+ closeCurrentUi({ store, render, globalUI, refs, emitResult: true });
411
+ return;
412
+ }
413
+
414
+ if (hasToasts) {
415
+ render();
416
+ }
326
417
  };
327
418
 
328
419
  export const handleComponentDialogAction = async (deps, payload) => {
@@ -1,4 +1,6 @@
1
1
  const VALID_DIALOG_SIZES = new Set(["sm", "md", "lg", "f"]);
2
+ const VALID_TOAST_SIZES = new Set(["sm", "md", "lg"]);
3
+ const VALID_TOAST_PHASES = new Set(["active", "exiting"]);
2
4
  const VALID_COMPONENT_DIALOG_ROLES = new Set(["confirm", "cancel"]);
3
5
 
4
6
  const DEFAULT_COMPONENT_DIALOG_BUTTONS = Object.freeze([
@@ -29,6 +31,14 @@ const normalizeDialogSize = (value, fallback = "md") => {
29
31
  return VALID_DIALOG_SIZES.has(value) ? value : fallback;
30
32
  };
31
33
 
34
+ const normalizeToastSize = (value, fallback = "sm") => {
35
+ return VALID_TOAST_SIZES.has(value) ? value : fallback;
36
+ };
37
+
38
+ const normalizeToastPhase = (value, fallback = "active") => {
39
+ return VALID_TOAST_PHASES.has(value) ? value : fallback;
40
+ };
41
+
32
42
  const normalizeComponentDialogActions = (value) => {
33
43
  const sourceButtons = Array.isArray(value?.buttons) && value.buttons.length > 0
34
44
  ? value.buttons
@@ -84,6 +94,8 @@ const createDefaultComponentDialogConfig = () => ({
84
94
  export const createInitialState = () => Object.freeze({
85
95
  isOpen: false,
86
96
  uiType: "dialog", // "dialog" | "dropdown" | "formDialog" | "componentDialog"
97
+ nextToastId: 0,
98
+ toasts: [],
87
99
  config: {
88
100
  status: undefined, // undefined | info | warning | error
89
101
  title: "",
@@ -201,6 +213,52 @@ export const setComponentDialogConfig = ({ state }, options = {}) => {
201
213
  state.isOpen = true;
202
214
  };
203
215
 
216
+ export const addToast = ({ state }, options = {}) => {
217
+ if (typeof options.message !== "string" || options.message.length === 0) {
218
+ throw new Error("message is required for showToast");
219
+ }
220
+
221
+ const nextToastId = (state.nextToastId ?? 0) + 1;
222
+ const toast = {
223
+ id: `toast-${nextToastId}`,
224
+ message: options.message,
225
+ size: normalizeToastSize(options.size ?? options.s, "sm"),
226
+ phase: "active",
227
+ };
228
+
229
+ state.nextToastId = nextToastId;
230
+ state.toasts = [...(state.toasts ?? []), toast];
231
+ };
232
+
233
+ export const removeToast = ({ state }, options = {}) => {
234
+ if (typeof options.id !== "string" || options.id.length === 0) {
235
+ return;
236
+ }
237
+
238
+ state.toasts = (state.toasts ?? []).filter((toast) => toast.id !== options.id);
239
+ };
240
+
241
+ export const setToastPhase = ({ state }, options = {}) => {
242
+ if (typeof options.id !== "string" || options.id.length === 0) {
243
+ return;
244
+ }
245
+
246
+ state.toasts = (state.toasts ?? []).map((toast) => {
247
+ if (toast.id !== options.id) {
248
+ return toast;
249
+ }
250
+
251
+ return {
252
+ ...toast,
253
+ phase: normalizeToastPhase(options.phase, "active"),
254
+ };
255
+ });
256
+ };
257
+
258
+ export const clearToasts = ({ state }) => {
259
+ state.toasts = [];
260
+ };
261
+
204
262
  export const closeAll = ({ state }) => {
205
263
  state.isOpen = false;
206
264
  state.uiType = "dialog"; // Reset to default type
@@ -212,6 +270,7 @@ export const selectFormDialogConfig = ({ state }) => state.formDialogConfig;
212
270
  export const selectComponentDialogConfig = ({ state }) => state.componentDialogConfig;
213
271
  export const selectUiType = ({ state }) => state.uiType;
214
272
  export const selectIsOpen = ({ state }) => state.isOpen;
273
+ export const selectToasts = ({ state }) => state.toasts ?? [];
215
274
 
216
275
  export const selectViewData = ({ state }) => {
217
276
  const isDialogOpen = state.isOpen && state.uiType === "dialog";
@@ -246,6 +305,13 @@ export const selectViewData = ({ state }) => {
246
305
  actions: componentDialogConfig.actions ?? normalizeComponentDialogActions(),
247
306
  key: componentDialogConfig.key ?? 0,
248
307
  },
308
+ toasts: Array.isArray(state.toasts)
309
+ ? state.toasts.map((toast) => ({
310
+ ...toast,
311
+ size: normalizeToastSize(toast.size, "sm"),
312
+ phase: normalizeToastPhase(toast.phase, "active"),
313
+ }))
314
+ : [],
249
315
  isDialogOpen,
250
316
  isFormDialogOpen,
251
317
  isComponentDialogOpen,
@@ -28,7 +28,44 @@ refs:
28
28
  handler: handleDropdownClose
29
29
  item-click:
30
30
  handler: handleDropdownItemClick
31
+ styles:
32
+ .toast-layer:
33
+ pointer-events: none
34
+ .toast-card:
35
+ pointer-events: auto
36
+ box-sizing: border-box
37
+ width: 33vw
38
+ max-width: calc(100vw - 2 * var(--spacing-lg))
39
+ opacity: 1
40
+ transform: translateY(0) scale(1)
41
+ animation: toast-in 220ms cubic-bezier(0.16, 1, 0.3, 1)
42
+ transition: opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), transform 180ms cubic-bezier(0.16, 1, 0.3, 1)
43
+ will-change: opacity, transform
44
+ .toast-card-md:
45
+ width: 50vw
46
+ .toast-card-lg:
47
+ width: 80vw
48
+ .toast-card-exiting:
49
+ opacity: 0
50
+ transform: translateY(calc(var(--spacing-sm) * -0.5)) scale(0.98)
51
+ .toast-message:
52
+ overflow-wrap: anywhere
53
+ '@keyframes toast-in':
54
+ from:
55
+ opacity: 0
56
+ transform: translateY(calc(var(--spacing-sm) * -1)) scale(0.96)
57
+ to:
58
+ opacity: 1
59
+ transform: translateY(0) scale(1)
60
+ '@media (prefers-reduced-motion: reduce)':
61
+ .toast-card:
62
+ animation: none
63
+ transition: none
31
64
  template:
65
+ - rtgl-view class=toast-layer pos=fix edge=t z=2100 w=f ah=c g=sm p=md:
66
+ - $for toast, i in toasts:
67
+ - 'rtgl-view class="toast-card toast-card-${toast.size} toast-card-${toast.phase}" key=toast-${toast.id} bgc=su bc=bo bw=xs br=md shadow=md ph=lg pv=md':
68
+ - rtgl-text class=toast-message ta=c w=f: ${toast.message}
32
69
  - rtgl-dialog#dialog ?open=${isDialogContainerOpen} s=${dialogSize}:
33
70
  - $if isFormDialogOpen:
34
71
  - rtgl-form#formDialog slot=content :form=${formDialogConfig.form} :defaultValues=${formDialogConfig.defaultValues} :context=${formDialogConfig.context} ?disabled=${formDialogConfig.disabled} key=form-dialog-${formDialogConfig.key}: null
@@ -13,6 +13,12 @@ propsSchema:
13
13
  type: string
14
14
  value:
15
15
  type: any
16
+ icon:
17
+ type: string
18
+ shortcut:
19
+ type: string
20
+ suffixText:
21
+ type: string
16
22
  testId:
17
23
  type: string
18
24
  selectedValue:
@@ -24,6 +24,45 @@ const stringifyProps = (props = {}) => {
24
24
  .join(" ");
25
25
  };
26
26
 
27
+ const hasOwnProp = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key);
28
+
29
+ const getOptionIcon = (option = {}) => {
30
+ return typeof option.icon === 'string' && option.icon.length > 0 ? option.icon : '';
31
+ };
32
+
33
+ const getOptionSuffixText = (option = {}) => {
34
+ if (typeof option.shortcut === 'string' && option.shortcut.length > 0) {
35
+ return option.shortcut;
36
+ }
37
+
38
+ if (typeof option.suffixText === 'string' && option.suffixText.length > 0) {
39
+ return option.suffixText;
40
+ }
41
+
42
+ return '';
43
+ };
44
+
45
+ const normalizeOption = (option = {}, index, currentValue, hoveredOptionId, hasIconColumn) => {
46
+ const isSelected = deepEqual(option.value, currentValue);
47
+ const isHovered = hoveredOptionId === index;
48
+ const icon = getOptionIcon(option);
49
+ const suffixText = getOptionSuffixText(option);
50
+
51
+ return {
52
+ ...option,
53
+ isSelected,
54
+ bgc: isHovered ? 'ac' : (isSelected ? 'mu' : ''),
55
+ hasIconSlot: hasIconColumn,
56
+ icon,
57
+ hasIcon: icon.length > 0,
58
+ iconColor: 'fg',
59
+ c: 'fg',
60
+ suffixText,
61
+ hasSuffixText: suffixText.length > 0,
62
+ suffixTextColor: 'mu-fg',
63
+ };
64
+ };
65
+
27
66
  export const createInitialState = () => Object.freeze({
28
67
  isOpen: false,
29
68
  position: {
@@ -49,22 +88,17 @@ export const selectViewData = ({ state, props }) => {
49
88
  let isPlaceholderLabel = true;
50
89
 
51
90
  const options = props.options || [];
52
- const selectedOption = options.find(opt => deepEqual(opt.value, currentValue));
91
+ const selectedOption = options.find((opt) => deepEqual(opt.value, currentValue));
53
92
  if (selectedOption) {
54
93
  displayLabel = selectedOption.label;
55
94
  isPlaceholderLabel = false;
56
95
  }
57
96
 
58
- // Map options to include isSelected flag and computed background color
97
+ const hasIconColumn = options.some((option) => hasOwnProp(option, 'icon'));
59
98
  const optionsWithSelection = options.map((option, index) => {
60
- const isSelected = deepEqual(option.value, currentValue);
61
- const isHovered = state.hoveredOptionId === index;
62
- return {
63
- ...option,
64
- isSelected,
65
- bgc: isHovered ? 'ac' : (isSelected ? 'mu' : '')
66
- };
99
+ return normalizeOption(option, index, currentValue, state.hoveredOptionId, hasIconColumn);
67
100
  });
101
+ const selectedOptionView = optionsWithSelection.find((option) => option.isSelected);
68
102
 
69
103
  return {
70
104
  containerAttrString,
@@ -75,6 +109,12 @@ export const selectViewData = ({ state, props }) => {
75
109
  selectedValue: currentValue,
76
110
  selectedLabel: displayLabel,
77
111
  selectedLabelColor: isPlaceholderLabel ? "mu-fg" : "fg",
112
+ selectedIcon: selectedOptionView?.icon || "",
113
+ hasSelectedIcon: !!selectedOptionView?.hasIcon,
114
+ selectedIconColor: isPlaceholderLabel ? "mu-fg" : "fg",
115
+ selectedSuffixText: selectedOptionView?.suffixText || "",
116
+ hasSelectedSuffixText: !!selectedOptionView?.hasSuffixText,
117
+ selectedSuffixTextColor: "mu-fg",
78
118
  selectButtonCursor: isDisabled ? "not-allowed" : "pointer",
79
119
  selectButtonHoverBorderColor: isDisabled ? "bo" : "ac",
80
120
  selectButtonTabIndex: isDisabled ? -1 : 0,
@@ -29,17 +29,36 @@ refs:
29
29
  handler: handleAddOptionMouseEnter
30
30
  mouseleave:
31
31
  handler: handleAddOptionMouseLeave
32
+ styles:
33
+ .icon-placeholder:
34
+ width: 16px
35
+ height: 16px
36
+ flex-shrink: 0
32
37
  template:
33
38
  - 'rtgl-view#selectButton style="display: inline-flex;" d=h av=c h=32 ph=md bw=xs bc=bo h-bc=${selectButtonHoverBorderColor} br=md bgc=su cur=${selectButtonCursor} ${containerAttrString} data-testid="select-button" role="button" tabindex=${selectButtonTabIndex} aria-disabled=${isDisabled}':
34
- - rtgl-view d=h av=c ah=s w=f:
35
- - rtgl-text w=1fg ta=s c=${selectedLabelColor} ellipsis: ${selectedLabel}
39
+ - rtgl-view d=h av=c ah=s w=f g=md:
40
+ - rtgl-view d=h av=c g=md w=1fg:
41
+ - $if hasSelectedIcon:
42
+ - rtgl-svg svg=${selectedIcon} wh=16 c=${selectedIconColor}: null
43
+ - rtgl-text w=1fg ta=s c=${selectedLabelColor} ellipsis: ${selectedLabel}
44
+ - $if hasSelectedSuffixText:
45
+ - rtgl-text s=xs c=${selectedSuffixTextColor} ta=e: ${selectedSuffixText}
36
46
  - $if showClear:
37
- - rtgl-svg#clearButton ml=md svg=x wh=16 c=mu-fg cur=pointer data-testid="select-clear-button": null
38
- - rtgl-svg ml=md svg=chevronDown wh=16 c=mu-fg: null
47
+ - rtgl-svg#clearButton svg=x wh=16 c=mu-fg cur=pointer data-testid="select-clear-button": null
48
+ - rtgl-svg svg=chevronDown wh=16 c=mu-fg: null
39
49
  - rtgl-popover#popover ?open=${isOpen} x=${position.x} y=${position.y} place=rs content-wh=300 content-g=xs content-sv=true content-pv=sm:
40
50
  - $for option, i in options:
41
51
  - rtgl-view#option${i} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId}:
42
- - rtgl-text s=sm: ${option.label}
52
+ - rtgl-view d=h av=c w=f g=md:
53
+ - rtgl-view d=h av=c g=md w=1fg:
54
+ - $if option.hasIconSlot:
55
+ - $if option.hasIcon:
56
+ - rtgl-svg wh=16 svg=${option.icon} c=${option.iconColor}: null
57
+ $else:
58
+ - div class=icon-placeholder aria-hidden="true": null
59
+ - rtgl-text s=sm c=${option.c} w=1fg ellipsis: ${option.label}
60
+ - $if option.hasSuffixText:
61
+ - rtgl-text s=xs c=${option.suffixTextColor} ta=e: ${option.suffixText}
43
62
  - $if showAddOption:
44
63
  - rtgl-view w=f bw=xs bc=mu bwt=sm: null
45
64
  - rtgl-view#optionAdd w=f ph=lg pv=md cur=pointer bgc=${addOptionBgc} data-testid="select-add-option":
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Creates a GlobalUI manager instance for controlling global UI components.
3
3
  * Provides methods for showing alerts, confirm dialogs, form dialogs,
4
- * dropdown menus, and closing all UI components.
4
+ * dropdown menus, toasts, and closing all UI components.
5
5
  *
6
6
  * @param {HTMLElement} globalUIElement - The globalUI component element
7
7
  * @returns {Object} GlobalUI manager instance
@@ -12,6 +12,7 @@
12
12
  * @returns {Function} returns.showFormDialog - Show a form dialog
13
13
  * @returns {Function} returns.showComponentDialog - Show a component dialog
14
14
  * @returns {Function} returns.showDropdownMenu - Show a dropdown menu
15
+ * @returns {Function} returns.showToast - Show a temporary toast message
15
16
  * @returns {Function} returns.closeAll - General-purpose function to close all currently open UI components
16
17
  */
17
18
  const createGlobalUI = (globalUIElement) => {
@@ -161,9 +162,27 @@ const createGlobalUI = (globalUIElement) => {
161
162
  return globalUIElement.transformedHandlers.handleShowDropdownMenu(options);
162
163
  },
163
164
 
165
+ /**
166
+ * Shows a top-centered toast message that auto-dismisses after 3 seconds.
167
+ *
168
+ * @param {Object} options - Toast configuration options
169
+ * @param {string} options.message - The toast message (required)
170
+ * @param {('sm'|'md'|'lg')} [options.size] - Toast width preset matching dialog sizing (default: "sm")
171
+ * @returns {void}
172
+ * @throws {Error} If globalUIElement is not initialized
173
+ */
174
+ showToast: (options) => {
175
+ if(!globalUIElement)
176
+ {
177
+ throw new Error("globalUIElement is not set. Make sure to initialize the global UI component and pass it to createGlobalUIManager.");
178
+ }
179
+ globalUIElement.transformedHandlers.handleShowToast(options);
180
+ },
181
+
164
182
  /**
165
183
  * General-purpose function to close all currently open UI components.
166
- * This includes dialogs, popovers, tooltips, selects, dropdown menus, and any other floating UI elements.
184
+ * This includes dialogs, dropdown menus, toasts, and any other floating UI elements
185
+ * managed by rtgl-global-ui.
167
186
  * Useful for programmatically cleaning up the entire UI surface.
168
187
  *
169
188
  * @returns {Promise<void>} Promise that resolves when all UI components are closed
@@ -255,7 +255,9 @@ class RettangoliPopoverElement extends HTMLElement {
255
255
 
256
256
  const hasContent = Array.from(wrapper.childNodes).some((node) => !this._isIgnorableTextNode(node));
257
257
 
258
- if (hasContent) {
258
+ // Keep the content wrapper slotted while open so callers can intentionally
259
+ // show an empty popover shell, such as an empty select menu.
260
+ if (hasContent || this.hasAttribute("open")) {
259
261
  wrapper.setAttribute("slot", "content");
260
262
  } else {
261
263
  wrapper.removeAttribute("slot");