@rettangoli/ui 1.6.0 → 1.7.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,335 @@
1
+ import { deepEqual } from "../../common.js";
2
+
3
+ const blacklistedProps = [
4
+ "id",
5
+ "class",
6
+ "style",
7
+ "slot",
8
+ "placeholder",
9
+ "options",
10
+ "selectedValues",
11
+ "onChange",
12
+ "addOption",
13
+ "disabled",
14
+ ];
15
+
16
+ const stringifyProps = (props = {}) => {
17
+ return Object.entries(props)
18
+ .filter(([key]) => !blacklistedProps.includes(key))
19
+ .map(([key, value]) => `${key}=${value}`)
20
+ .join(" ");
21
+ };
22
+
23
+ const hasOwnProp = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key);
24
+
25
+ const getOptionType = (option = {}) => {
26
+ if (option.type === "section") {
27
+ return "section";
28
+ }
29
+
30
+ if (option.type === "separator") {
31
+ return "separator";
32
+ }
33
+
34
+ return "item";
35
+ };
36
+
37
+ const isSelectableOption = (option = {}) => getOptionType(option) === "item";
38
+
39
+ const getOptionIcon = (option = {}) => {
40
+ return typeof option.icon === "string" && option.icon.length > 0 ? option.icon : "";
41
+ };
42
+
43
+ const getOptionSuffixText = (option = {}) => {
44
+ if (typeof option.shortcut === "string" && option.shortcut.length > 0) {
45
+ return option.shortcut;
46
+ }
47
+
48
+ if (typeof option.suffixText === "string" && option.suffixText.length > 0) {
49
+ return option.suffixText;
50
+ }
51
+
52
+ return "";
53
+ };
54
+
55
+ const normalizeSelectedValues = (selectedValues) => {
56
+ if (!Array.isArray(selectedValues)) {
57
+ return [];
58
+ }
59
+
60
+ return [...selectedValues];
61
+ };
62
+
63
+ const sameValueArray = (left = [], right = []) => {
64
+ if (left.length !== right.length) {
65
+ return false;
66
+ }
67
+
68
+ return left.every((value, index) => deepEqual(value, right[index]));
69
+ };
70
+
71
+ const stringifyKeyPart = (value) => {
72
+ if (value === undefined) {
73
+ return "undefined";
74
+ }
75
+
76
+ if (value === null) {
77
+ return "null";
78
+ }
79
+
80
+ if (typeof value === "string") {
81
+ return value;
82
+ }
83
+
84
+ try {
85
+ return JSON.stringify(value);
86
+ } catch {
87
+ return String(value);
88
+ }
89
+ };
90
+
91
+ const isSelectedValue = (selectedValues = [], candidate) => {
92
+ return selectedValues.some((value) => deepEqual(value, candidate));
93
+ };
94
+
95
+ const getCurrentValues = ({ state, props }) => {
96
+ if (state.hasSelectedValues) {
97
+ return normalizeSelectedValues(state.selectedValues);
98
+ }
99
+
100
+ return normalizeSelectedValues(props.selectedValues);
101
+ };
102
+
103
+ const getDraftValues = ({ state, props }) => {
104
+ if (state.isOpen) {
105
+ return normalizeSelectedValues(state.draftSelectedValues);
106
+ }
107
+
108
+ return getCurrentValues({ state, props });
109
+ };
110
+
111
+ const stringifyFallbackLabel = (value) => {
112
+ if (value === undefined || value === null) {
113
+ return "";
114
+ }
115
+
116
+ return String(value);
117
+ };
118
+
119
+ const findMatchingOption = (options = [], value) => {
120
+ return options.find((option) => isSelectableOption(option) && deepEqual(option.value, value));
121
+ };
122
+
123
+ const buildTagStyle = ({ isSelected = true, isAddChip = false } = {}) => {
124
+ const baseStyle = [
125
+ "--tag-border-radius: var(--border-radius-md)",
126
+ "--muted-foreground: var(--foreground)",
127
+ ];
128
+
129
+ if (isAddChip || !isSelected) {
130
+ baseStyle.push("--muted: color-mix(in srgb, var(--muted) 82%, var(--background) 18%)");
131
+ }
132
+
133
+ return `${baseStyle.join("; ")};`;
134
+ };
135
+
136
+ const buildPopoverSignature = (options = []) => {
137
+ if (!Array.isArray(options) || options.length === 0) {
138
+ return "empty";
139
+ }
140
+
141
+ return options.map((option, index) => {
142
+ const type = getOptionType(option);
143
+
144
+ if (type === "section") {
145
+ return `section:${index}:${option.label || ""}`;
146
+ }
147
+
148
+ if (type === "separator") {
149
+ return `separator:${index}`;
150
+ }
151
+
152
+ return `item:${index}:${option.label || ""}:${stringifyKeyPart(option.value)}`;
153
+ }).join("|");
154
+ };
155
+
156
+ const buildPopoverKey = (options = []) => {
157
+ const signature = buildPopoverSignature(options);
158
+ let hash = 0;
159
+
160
+ for (let index = 0; index < signature.length; index += 1) {
161
+ hash = ((hash << 5) - hash + signature.charCodeAt(index)) >>> 0;
162
+ }
163
+
164
+ return `tagSelectPopover${hash}`;
165
+ };
166
+
167
+ const normalizeOption = ({
168
+ option = {},
169
+ index,
170
+ selectedValues = [],
171
+ hasIconColumn = false,
172
+ }) => {
173
+ const type = getOptionType(option);
174
+ const isSection = type === "section";
175
+ const isSeparator = type === "separator";
176
+ const isItem = type === "item";
177
+
178
+ if (isSection || isSeparator) {
179
+ return {
180
+ ...option,
181
+ index,
182
+ type,
183
+ isSection,
184
+ isSeparator,
185
+ isItem,
186
+ };
187
+ }
188
+
189
+ const isSelected = isSelectedValue(selectedValues, option.value);
190
+ const icon = getOptionIcon(option);
191
+ const baseSuffixText = getOptionSuffixText(option);
192
+ const suffixText = isSelected ? (baseSuffixText || "Added") : baseSuffixText;
193
+
194
+ return {
195
+ ...option,
196
+ index,
197
+ type,
198
+ isSection,
199
+ isSeparator,
200
+ isItem,
201
+ isSelected,
202
+ hasIconSlot: hasIconColumn,
203
+ icon,
204
+ hasIcon: icon.length > 0,
205
+ cursor: "pointer",
206
+ tagStyle: buildTagStyle({ isSelected }),
207
+ suffixText,
208
+ hasSuffixText: suffixText.length > 0,
209
+ };
210
+ };
211
+
212
+ export const createInitialState = () =>
213
+ Object.freeze({
214
+ isOpen: false,
215
+ position: {
216
+ x: 0,
217
+ y: 0,
218
+ w: 240,
219
+ },
220
+ selectedValues: [],
221
+ draftSelectedValues: [],
222
+ hasSelectedValues: false,
223
+ });
224
+
225
+ export const selectViewData = ({ state, props }) => {
226
+ const containerAttrString = stringifyProps(props);
227
+ const isDisabled = !!props.disabled;
228
+ const currentValues = getCurrentValues({ state, props });
229
+ const draftValues = getDraftValues({ state, props });
230
+ const options = Array.isArray(props.options) ? props.options : [];
231
+ const hasIconColumn = options.some((option) => isSelectableOption(option) && hasOwnProp(option, "icon"));
232
+ const normalizedOptions = options.map((option, index) =>
233
+ normalizeOption({
234
+ option,
235
+ index,
236
+ selectedValues: draftValues,
237
+ hasIconColumn,
238
+ }),
239
+ );
240
+
241
+ const selectedTags = currentValues.map((value, selectionIndex) => {
242
+ const matchedOption = findMatchingOption(options, value);
243
+ const icon = matchedOption ? getOptionIcon(matchedOption) : "";
244
+
245
+ return {
246
+ value,
247
+ selectionIndex,
248
+ label: matchedOption?.label || stringifyFallbackLabel(value),
249
+ icon,
250
+ hasIcon: icon.length > 0,
251
+ testId: `tag-select-tag-${selectionIndex}`,
252
+ };
253
+ });
254
+
255
+ const hasSelectableOptions = normalizedOptions.some((option) => option.isItem);
256
+ const hasDraftChanges = !sameValueArray(currentValues, draftValues);
257
+
258
+ return {
259
+ containerAttrString,
260
+ isDisabled,
261
+ isOpen: state.isOpen,
262
+ position: state.position,
263
+ options: normalizedOptions,
264
+ hasSelectableOptions,
265
+ popoverKey: buildPopoverKey(options),
266
+ placeholder: props.placeholder || "Add tag",
267
+ selectedTags,
268
+ hasSelectedTags: selectedTags.length > 0,
269
+ triggerTagStyle: buildTagStyle({ isSelected: true }),
270
+ placeholderTagStyle: buildTagStyle({ isAddChip: true }),
271
+ triggerCursor: isDisabled ? "not-allowed" : "pointer",
272
+ triggerTabIndex: isDisabled ? -1 : 0,
273
+ showAddOption: true,
274
+ addOptionLabel: props.addOption?.label || "Add tag",
275
+ hasDraftChanges,
276
+ submitDisabled: isDisabled || !hasDraftChanges,
277
+ submitLabel: "Save",
278
+ };
279
+ };
280
+
281
+ export const selectSelectedValues = ({ state }) => {
282
+ return normalizeSelectedValues(state.selectedValues);
283
+ };
284
+
285
+ export const selectHasSelectedValues = ({ state }) => {
286
+ return !!state.hasSelectedValues;
287
+ };
288
+
289
+ export const selectDraftSelectedValues = ({ state }) => {
290
+ return normalizeSelectedValues(state.draftSelectedValues);
291
+ };
292
+
293
+ export const openOptionsPopover = ({ state }, payload = {}) => {
294
+ const { position, values } = payload;
295
+ state.position = {
296
+ ...state.position,
297
+ ...(position || {}),
298
+ };
299
+ state.draftSelectedValues = normalizeSelectedValues(values);
300
+ state.isOpen = true;
301
+ };
302
+
303
+ export const closeOptionsPopover = ({ state }) => {
304
+ state.isOpen = false;
305
+ state.draftSelectedValues = [];
306
+ };
307
+
308
+ export const updateSelectedValues = ({ state }, payload = {}) => {
309
+ const values = normalizeSelectedValues(payload.values);
310
+ state.selectedValues = values;
311
+ state.hasSelectedValues = true;
312
+ if (payload.syncDraft || !state.isOpen) {
313
+ state.draftSelectedValues = [...values];
314
+ }
315
+ };
316
+
317
+ export const toggleDraftSelectedValue = ({ state }, payload = {}) => {
318
+ const draftValues = normalizeSelectedValues(state.draftSelectedValues);
319
+ const value = payload.value;
320
+ const existingIndex = draftValues.findIndex((currentValue) => deepEqual(currentValue, value));
321
+
322
+ if (existingIndex >= 0) {
323
+ state.draftSelectedValues = draftValues.filter((_, index) => index !== existingIndex);
324
+ return;
325
+ }
326
+
327
+ state.draftSelectedValues = [...draftValues, value];
328
+ };
329
+
330
+ export const commitDraftSelectedValues = ({ state }) => {
331
+ state.selectedValues = normalizeSelectedValues(state.draftSelectedValues);
332
+ state.hasSelectedValues = true;
333
+ state.isOpen = false;
334
+ state.draftSelectedValues = [];
335
+ };
@@ -0,0 +1,61 @@
1
+ refs:
2
+ trigger:
3
+ eventListeners:
4
+ click:
5
+ handler: handleTriggerClick
6
+ keydown:
7
+ handler: handleTriggerKeyDown
8
+ popover:
9
+ eventListeners:
10
+ close:
11
+ handler: handlePopoverClose
12
+ option*:
13
+ eventListeners:
14
+ click:
15
+ handler: handleOptionClick
16
+ keydown:
17
+ handler: handleOptionKeyDown
18
+ submitButton:
19
+ eventListeners:
20
+ click:
21
+ handler: handleSubmitClick
22
+ addOptionButton:
23
+ eventListeners:
24
+ click:
25
+ handler: handleAddOptionClick
26
+ styles:
27
+ .icon-placeholder:
28
+ width: 16px
29
+ height: 16px
30
+ flex-shrink: 0
31
+ template:
32
+ - 'rtgl-view#trigger d=h av=c g=sm cur=${triggerCursor} ${containerAttrString} data-testid="tag-select-trigger" role="button" tabindex=${triggerTabIndex} aria-disabled=${isDisabled} style="min-height: 24px;"':
33
+ - rtgl-view d=h av=c wrap g=sm w=1fg:
34
+ - $if !hasSelectedTags:
35
+ - 'rtgl-tag v=mu style="${placeholderTagStyle}"':
36
+ ${placeholder}
37
+ - $for tag, i in selectedTags:
38
+ - 'rtgl-tag#tag${tag.selectionIndex} data-selection-index=${tag.selectionIndex} pre=${tag.icon} data-testid=${tag.testId} v=mu style="${triggerTagStyle}"':
39
+ ${tag.label}
40
+ - 'div#${popoverKey} style="display: contents;"':
41
+ - rtgl-popover#popover ?open=${isOpen} x=${position.x} y=${position.y} place=bs content-w=${position.w} content-g=sm content-sv=true content-pv=sm:
42
+ - $if !hasSelectableOptions:
43
+ - rtgl-text s=sm c=mu-fg: No tags available
44
+ - rtgl-view d=h wrap g=sm w=f:
45
+ - $for option, i in options:
46
+ - $if option.isSection:
47
+ - rtgl-view w=f pt=xs:
48
+ - rtgl-text s=xs c=mu-fg: ${option.label}
49
+ $elif option.isItem:
50
+ - 'rtgl-view#option${option.index} cur=${option.cursor} data-testid=${option.testId} role="button" tabindex=0 aria-pressed=${option.isSelected}':
51
+ - 'rtgl-tag v=mu pre=${option.icon} style="${option.tagStyle}"':
52
+ ${option.label}
53
+ $elif option.isSeparator:
54
+ - rtgl-view w=f h=1 bgc=mu my=xs: null
55
+ - $if showAddOption:
56
+ - rtgl-button#addOptionButton v=gh s=sm pre=plus data-testid="tag-select-add-option":
57
+ ${addOptionLabel}
58
+ - rtgl-view d=h av=c g=sm w=f pt=sm:
59
+ - rtgl-view w=1fg: null
60
+ - rtgl-button#submitButton v=pr s=sm data-testid="tag-select-submit" ?disabled=${submitDisabled}:
61
+ ${submitLabel}
@@ -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
  */