@rettangoli/ui 1.5.0 → 1.6.1

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.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "A UI component library for building web interfaces.",
5
5
  "main": "dist/rettangoli-esm.min.js",
6
6
  "type": "module",
@@ -1,4 +1,16 @@
1
1
 
2
+ const getItemType = (item = {}) => {
3
+ if (item.type === 'section' || item.type === 'label') {
4
+ return 'section';
5
+ }
6
+
7
+ if (item.type === 'separator') {
8
+ return 'separator';
9
+ }
10
+
11
+ return 'item';
12
+ };
13
+
2
14
  export const handleClosePopover = (deps, payload) => {
3
15
  const { dispatchEvent } = deps;
4
16
  dispatchEvent(new CustomEvent('close'));
@@ -9,7 +21,7 @@ export const handleClickMenuItem = (deps, payload) => {
9
21
  const event = payload._event;
10
22
  const index = Number(event.currentTarget.dataset.index ?? event.currentTarget.id.slice('option'.length));
11
23
  const item = props.items[index];
12
- const itemType = item?.type || 'item';
24
+ const itemType = getItemType(item);
13
25
 
14
26
  if (!item || itemType !== 'item' || item.disabled) {
15
27
  event.preventDefault();
@@ -12,9 +12,10 @@ propsSchema:
12
12
  type:
13
13
  type: string
14
14
  enum:
15
- - label
15
+ - section
16
16
  - item
17
17
  - separator
18
+ - label
18
19
  id:
19
20
  type: string
20
21
  icon:
@@ -3,12 +3,23 @@ export const createInitialState = () => Object.freeze({
3
3
  });
4
4
 
5
5
  const escapeAttrValue = (value) => `${value}`.replace(/"/g, '"');
6
+ const getItemType = (item = {}) => {
7
+ if (item.type === 'section' || item.type === 'label') {
8
+ return 'section';
9
+ }
10
+
11
+ if (item.type === 'separator') {
12
+ return 'separator';
13
+ }
14
+
15
+ return 'item';
16
+ };
6
17
 
7
18
  const normalizeItems = (items) => {
8
19
  return items.map((item, index) => {
9
- const type = item.type || 'item';
20
+ const type = getItemType(item);
10
21
  const isSeparator = type === 'separator';
11
- const isLabel = type === 'label';
22
+ const isSection = type === 'section';
12
23
  const isItem = type === 'item';
13
24
  const isDisabled = !!item.disabled;
14
25
  const isInteractive = isItem && !isDisabled;
@@ -34,7 +45,7 @@ const normalizeItems = (items) => {
34
45
  index,
35
46
  type,
36
47
  isSeparator,
37
- isLabel,
48
+ isSection,
38
49
  isItem,
39
50
  isDisabled,
40
51
  isInteractive,
@@ -20,7 +20,7 @@ styles:
20
20
  template:
21
21
  - rtgl-popover#popover ?open=${open} x=${x} y=${y} place=${place} content-w=${w} content-h=${h} content-sv=true content-g=xs content-pv=sm:
22
22
  - $for item, i in items:
23
- - $if item.isLabel:
23
+ - $if item.isSection:
24
24
  - rtgl-view w=f p=md:
25
25
  - rtgl-text s=xs c=mu-fg: ${item.label}
26
26
  $elif item.isItem && item.isDisabled:
@@ -1,6 +1,7 @@
1
1
  const VALID_DIALOG_SIZES = new Set(["sm", "md", "lg", "f"]);
2
2
  const VALID_TOAST_SIZES = new Set(["sm", "md", "lg"]);
3
3
  const VALID_TOAST_PHASES = new Set(["active", "exiting"]);
4
+ const VALID_TOAST_POSITIONS = new Set(["top", "bottom"]);
4
5
  const VALID_COMPONENT_DIALOG_ROLES = new Set(["confirm", "cancel"]);
5
6
 
6
7
  const DEFAULT_COMPONENT_DIALOG_BUTTONS = Object.freeze([
@@ -39,6 +40,10 @@ const normalizeToastPhase = (value, fallback = "active") => {
39
40
  return VALID_TOAST_PHASES.has(value) ? value : fallback;
40
41
  };
41
42
 
43
+ const normalizeToastPosition = (value, fallback = "top") => {
44
+ return VALID_TOAST_POSITIONS.has(value) ? value : fallback;
45
+ };
46
+
42
47
  const normalizeComponentDialogActions = (value) => {
43
48
  const sourceButtons = Array.isArray(value?.buttons) && value.buttons.length > 0
44
49
  ? value.buttons
@@ -223,6 +228,7 @@ export const addToast = ({ state }, options = {}) => {
223
228
  id: `toast-${nextToastId}`,
224
229
  message: options.message,
225
230
  size: normalizeToastSize(options.size ?? options.s, "sm"),
231
+ position: normalizeToastPosition(options.position, "top"),
226
232
  phase: "active",
227
233
  };
228
234
 
@@ -277,6 +283,14 @@ export const selectViewData = ({ state }) => {
277
283
  const isFormDialogOpen = state.isOpen && state.uiType === "formDialog";
278
284
  const isComponentDialogOpen = state.isOpen && state.uiType === "componentDialog";
279
285
  const componentDialogConfig = state.componentDialogConfig ?? createDefaultComponentDialogConfig();
286
+ const normalizedToasts = Array.isArray(state.toasts)
287
+ ? state.toasts.map((toast) => ({
288
+ ...toast,
289
+ size: normalizeToastSize(toast.size, "sm"),
290
+ position: normalizeToastPosition(toast.position, "top"),
291
+ phase: normalizeToastPhase(toast.phase, "active"),
292
+ }))
293
+ : [];
280
294
 
281
295
  return {
282
296
  isOpen: state.isOpen,
@@ -305,13 +319,9 @@ export const selectViewData = ({ state }) => {
305
319
  actions: componentDialogConfig.actions ?? normalizeComponentDialogActions(),
306
320
  key: componentDialogConfig.key ?? 0,
307
321
  },
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
- : [],
322
+ toasts: normalizedToasts,
323
+ topToasts: normalizedToasts.filter((toast) => toast.position === "top"),
324
+ bottomToasts: normalizedToasts.filter((toast) => toast.position === "bottom"),
315
325
  isDialogOpen,
316
326
  isFormDialogOpen,
317
327
  isComponentDialogOpen,
@@ -38,34 +38,53 @@ styles:
38
38
  max-width: calc(100vw - 2 * var(--spacing-lg))
39
39
  opacity: 1
40
40
  transform: translateY(0) scale(1)
41
- animation: toast-in 220ms cubic-bezier(0.16, 1, 0.3, 1)
42
41
  transition: opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), transform 180ms cubic-bezier(0.16, 1, 0.3, 1)
43
42
  will-change: opacity, transform
43
+ .toast-card-top:
44
+ animation: toast-in-top 220ms cubic-bezier(0.16, 1, 0.3, 1)
45
+ .toast-card-bottom:
46
+ animation: toast-in-bottom 220ms cubic-bezier(0.16, 1, 0.3, 1)
44
47
  .toast-card-md:
45
48
  width: 50vw
46
49
  .toast-card-lg:
47
50
  width: 80vw
48
- .toast-card-exiting:
51
+ .toast-card-top.toast-card-exiting:
49
52
  opacity: 0
50
53
  transform: translateY(calc(var(--spacing-sm) * -0.5)) scale(0.98)
54
+ .toast-card-bottom.toast-card-exiting:
55
+ opacity: 0
56
+ transform: translateY(calc(var(--spacing-sm) * 0.5)) scale(0.98)
51
57
  .toast-message:
52
58
  overflow-wrap: anywhere
53
- '@keyframes toast-in':
59
+ '@keyframes toast-in-top':
54
60
  from:
55
61
  opacity: 0
56
62
  transform: translateY(calc(var(--spacing-sm) * -1)) scale(0.96)
57
63
  to:
58
64
  opacity: 1
59
65
  transform: translateY(0) scale(1)
66
+ '@keyframes toast-in-bottom':
67
+ from:
68
+ opacity: 0
69
+ transform: translateY(var(--spacing-sm)) scale(0.96)
70
+ to:
71
+ opacity: 1
72
+ transform: translateY(0) scale(1)
60
73
  '@media (prefers-reduced-motion: reduce)':
61
74
  .toast-card:
62
75
  animation: none
63
76
  transition: none
64
77
  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}
78
+ - $if topToasts.length > 0:
79
+ - rtgl-view class="toast-layer toast-layer-top" pos=fix edge=t z=2100 w=f ah=c g=sm ph=md pt=xl:
80
+ - $for toast, i in topToasts:
81
+ - 'rtgl-view class="toast-card toast-card-top toast-card-${toast.size} toast-card-${toast.phase}" key=toast-${toast.id} bgc=su bc=bo bw=xs br=md shadow=md ph=xl pv=lg':
82
+ - rtgl-text class=toast-message ta=c w=f: ${toast.message}
83
+ - $if bottomToasts.length > 0:
84
+ - rtgl-view class="toast-layer toast-layer-bottom" pos=fix edge=b z=2100 w=f ah=c g=sm ph=md pb=xl:
85
+ - $for toast, i in bottomToasts:
86
+ - 'rtgl-view class="toast-card toast-card-bottom toast-card-${toast.size} toast-card-${toast.phase}" key=toast-${toast.id} bgc=su bc=bo bw=xs br=md shadow=md ph=xl pv=lg':
87
+ - rtgl-text class=toast-message ta=c w=f: ${toast.message}
69
88
  - rtgl-dialog#dialog ?open=${isDialogContainerOpen} s=${dialogSize}:
70
89
  - $if isFormDialogOpen:
71
90
  - rtgl-form#formDialog slot=content :form=${formDialogConfig.form} :defaultValues=${formDialogConfig.defaultValues} :context=${formDialogConfig.context} ?disabled=${formDialogConfig.disabled} key=form-dialog-${formDialogConfig.key}: null
@@ -1,10 +1,22 @@
1
1
  import { deepEqual } from '../../common.js';
2
2
 
3
+ const getOptionType = (option = {}) => {
4
+ if (option.type === 'section') {
5
+ return 'section';
6
+ }
7
+
8
+ if (option.type === 'separator') {
9
+ return 'separator';
10
+ }
11
+
12
+ return 'item';
13
+ };
14
+
3
15
  export const handleBeforeMount = (deps) => {
4
16
  const { store, props, render } = deps;
5
17
 
6
18
  if (props.selectedValue !== null && props.selectedValue !== undefined && props.options) {
7
- const selectedOption = props.options.find(opt => deepEqual(opt.value, props.selectedValue));
19
+ const selectedOption = props.options.find((opt) => getOptionType(opt) === 'item' && deepEqual(opt.value, props.selectedValue));
8
20
  if (selectedOption) {
9
21
  store.updateSelectedValue({
10
22
  value: selectedOption?.value
@@ -48,7 +60,7 @@ export const handleButtonClick = (deps, payload) => {
48
60
  const currentValue = storeSelectedValue !== null ? storeSelectedValue : props.selectedValue;
49
61
  let selectedIndex = null;
50
62
  if (currentValue !== null && currentValue !== undefined && props.options) {
51
- selectedIndex = props.options.findIndex(opt => deepEqual(opt.value, currentValue));
63
+ selectedIndex = props.options.findIndex((opt) => getOptionType(opt) === 'item' && deepEqual(opt.value, currentValue));
52
64
  if (selectedIndex === -1) selectedIndex = null;
53
65
  }
54
66
 
@@ -87,6 +99,9 @@ export const handleOptionClick = (deps, payload) => {
87
99
  const index = Number(id);
88
100
 
89
101
  const option = props.options[id];
102
+ if (getOptionType(option) !== 'item') {
103
+ return;
104
+ }
90
105
 
91
106
  // Update internal state
92
107
  store.updateSelectedValue({ value: option?.value });
@@ -9,6 +9,12 @@ propsSchema:
9
9
  items:
10
10
  type: object
11
11
  properties:
12
+ type:
13
+ type: string
14
+ enum:
15
+ - section
16
+ - item
17
+ - separator
12
18
  label:
13
19
  type: string
14
20
  value:
@@ -25,6 +25,18 @@ const stringifyProps = (props = {}) => {
25
25
  };
26
26
 
27
27
  const hasOwnProp = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key);
28
+ const getOptionType = (option = {}) => {
29
+ if (option.type === 'section') {
30
+ return 'section';
31
+ }
32
+
33
+ if (option.type === 'separator') {
34
+ return 'separator';
35
+ }
36
+
37
+ return 'item';
38
+ };
39
+ const isSelectableOption = (option = {}) => getOptionType(option) === 'item';
28
40
 
29
41
  const getOptionIcon = (option = {}) => {
30
42
  return typeof option.icon === 'string' && option.icon.length > 0 ? option.icon : '';
@@ -43,6 +55,33 @@ const getOptionSuffixText = (option = {}) => {
43
55
  };
44
56
 
45
57
  const normalizeOption = (option = {}, index, currentValue, hoveredOptionId, hasIconColumn) => {
58
+ const type = getOptionType(option);
59
+ const isSection = type === 'section';
60
+ const isSeparator = type === 'separator';
61
+ const isItem = type === 'item';
62
+
63
+ if (isSection) {
64
+ return {
65
+ ...option,
66
+ index,
67
+ type,
68
+ isSection,
69
+ isSeparator,
70
+ isItem,
71
+ };
72
+ }
73
+
74
+ if (isSeparator) {
75
+ return {
76
+ ...option,
77
+ index,
78
+ type,
79
+ isSection,
80
+ isSeparator,
81
+ isItem,
82
+ };
83
+ }
84
+
46
85
  const isSelected = deepEqual(option.value, currentValue);
47
86
  const isHovered = hoveredOptionId === index;
48
87
  const icon = getOptionIcon(option);
@@ -50,6 +89,11 @@ const normalizeOption = (option = {}, index, currentValue, hoveredOptionId, hasI
50
89
 
51
90
  return {
52
91
  ...option,
92
+ index,
93
+ type,
94
+ isSection,
95
+ isSeparator,
96
+ isItem,
53
97
  isSelected,
54
98
  bgc: isHovered ? 'ac' : (isSelected ? 'mu' : ''),
55
99
  hasIconSlot: hasIconColumn,
@@ -88,17 +132,17 @@ export const selectViewData = ({ state, props }) => {
88
132
  let isPlaceholderLabel = true;
89
133
 
90
134
  const options = props.options || [];
91
- const selectedOption = options.find((opt) => deepEqual(opt.value, currentValue));
135
+ const selectedOption = options.find((opt) => isSelectableOption(opt) && deepEqual(opt.value, currentValue));
92
136
  if (selectedOption) {
93
137
  displayLabel = selectedOption.label;
94
138
  isPlaceholderLabel = false;
95
139
  }
96
140
 
97
- const hasIconColumn = options.some((option) => hasOwnProp(option, 'icon'));
141
+ const hasIconColumn = options.some((option) => isSelectableOption(option) && hasOwnProp(option, 'icon'));
98
142
  const optionsWithSelection = options.map((option, index) => {
99
143
  return normalizeOption(option, index, currentValue, state.hoveredOptionId, hasIconColumn);
100
144
  });
101
- const selectedOptionView = optionsWithSelection.find((option) => option.isSelected);
145
+ const selectedOptionView = optionsWithSelection.find((option) => option.isItem && option.isSelected);
102
146
 
103
147
  return {
104
148
  containerAttrString,
@@ -48,17 +48,23 @@ template:
48
48
  - rtgl-svg svg=chevronDown wh=16 c=mu-fg: null
49
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:
50
50
  - $for option, i in options:
51
- - rtgl-view#option${i} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId}:
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}
51
+ - $if option.isSection:
52
+ - rtgl-view w=f p=md:
53
+ - rtgl-text s=xs c=mu-fg: ${option.label}
54
+ $elif option.isItem:
55
+ - rtgl-view#option${option.index} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId}:
56
+ - rtgl-view d=h av=c w=f g=md:
57
+ - rtgl-view d=h av=c g=md w=1fg:
58
+ - $if option.hasIconSlot:
59
+ - $if option.hasIcon:
60
+ - rtgl-svg wh=16 svg=${option.icon} c=${option.iconColor}: null
61
+ $else:
62
+ - div class=icon-placeholder aria-hidden="true": null
63
+ - rtgl-text s=sm c=${option.c} w=1fg ellipsis: ${option.label}
64
+ - $if option.hasSuffixText:
65
+ - rtgl-text s=xs c=${option.suffixTextColor} ta=e: ${option.suffixText}
66
+ $elif option.isSeparator:
67
+ - rtgl-view w=f h=1 bgc=mu mv=sm: null
62
68
  - $if showAddOption:
63
69
  - rtgl-view w=f bw=xs bc=mu bwt=sm: null
64
70
  - rtgl-view#optionAdd w=f ph=lg pv=md cur=pointer bgc=${addOptionBgc} data-testid="select-add-option":
@@ -163,11 +163,12 @@ const createGlobalUI = (globalUIElement) => {
163
163
  },
164
164
 
165
165
  /**
166
- * Shows a top-centered toast message that auto-dismisses after 3 seconds.
166
+ * Shows a toast message that auto-dismisses after 3 seconds.
167
167
  *
168
168
  * @param {Object} options - Toast configuration options
169
169
  * @param {string} options.message - The toast message (required)
170
170
  * @param {('sm'|'md'|'lg')} [options.size] - Toast width preset matching dialog sizing (default: "sm")
171
+ * @param {('top'|'bottom')} [options.position] - Vertical viewport placement (default: "top")
171
172
  * @returns {void}
172
173
  * @throws {Error} If globalUIElement is not initialized
173
174
  */