@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.
- package/dist/rettangoli-iife-ui.min.js +55 -55
- package/package.json +1 -1
- package/src/components/form/form.handlers.js +8 -0
- package/src/components/form/form.methods.js +9 -4
- package/src/components/form/form.store.js +5 -1
- package/src/components/form/form.view.yaml +2 -0
- package/src/components/globalUi/globalUi.store.js +17 -7
- package/src/components/globalUi/globalUi.view.yaml +26 -7
- package/src/components/tagSelect/tagSelect.handlers.js +192 -0
- package/src/components/tagSelect/tagSelect.methods.js +32 -0
- package/src/components/tagSelect/tagSelect.schema.yaml +51 -0
- package/src/components/tagSelect/tagSelect.store.js +335 -0
- package/src/components/tagSelect/tagSelect.view.yaml +61 -0
- package/src/deps/createGlobalUI.js +2 -1
|
@@ -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
|
|
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
|
*/
|