@rettangoli/ui 1.6.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/ui",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "A UI component library for building web interfaces.",
5
5
  "main": "dist/rettangoli-esm.min.js",
6
6
  "type": "module",
@@ -71,6 +71,14 @@ const updateFieldAttributes = ({
71
71
  }
72
72
  }
73
73
 
74
+ if (field.type === "tag-select" && ref.store?.updateSelectedValues) {
75
+ const value = get(formValues, field.name);
76
+ ref.store.updateSelectedValues({ values: Array.isArray(value) ? value : [] });
77
+ if (typeof ref.render === "function") {
78
+ ref.render();
79
+ }
80
+ }
81
+
74
82
  if (field.type === "checkbox") {
75
83
  const value = get(formValues, field.name);
76
84
  if (value) {
@@ -60,8 +60,13 @@ const syncFieldValueAttribute = ({ ref, fieldType, value, forceRefresh = false }
60
60
 
61
61
  const syncChoiceFieldState = ({ ref, value }) => {
62
62
  if (!ref) return;
63
- if (!ref?.store?.updateSelectedValue) return;
64
- ref.store.updateSelectedValue({ value });
63
+ if (ref?.store?.updateSelectedValues) {
64
+ ref.store.updateSelectedValues({ values: Array.isArray(value) ? value : [] });
65
+ } else if (ref?.store?.updateSelectedValue) {
66
+ ref.store.updateSelectedValue({ value });
67
+ } else {
68
+ return;
69
+ }
65
70
  if (typeof ref.render === "function") {
66
71
  ref.render();
67
72
  }
@@ -93,7 +98,7 @@ const syncChoiceRefsFromValues = ({ root, values = {} }) => {
93
98
  if (!root || typeof root.querySelectorAll !== "function") return;
94
99
 
95
100
  const choiceRefs = root.querySelectorAll(
96
- "rtgl-select[data-field-name], rtgl-segmented-control[data-field-name]",
101
+ "rtgl-select[data-field-name], rtgl-tag-select[data-field-name], rtgl-segmented-control[data-field-name]",
97
102
  );
98
103
  choiceRefs.forEach((ref) => {
99
104
  const fieldName = ref.dataset?.fieldName;
@@ -142,7 +147,7 @@ export const setValues = function (payload = {}) {
142
147
  forceRefresh: true,
143
148
  });
144
149
 
145
- if (typeof ref?.tagName === "string" && ref.tagName.toUpperCase() === "RTGL-SELECT") {
150
+ if (["select", "tag-select", "segmented-control"].includes(field.type)) {
146
151
  syncChoiceFieldState({ ref, value });
147
152
  }
148
153
 
@@ -284,6 +284,7 @@ export const validateField = (field, value) => {
284
284
  value === undefined ||
285
285
  value === null ||
286
286
  value === "" ||
287
+ (Array.isArray(value) && value.length === 0) ||
287
288
  (typeof value === "boolean" && value === false);
288
289
  // For numbers, 0 is a valid value
289
290
  const isEmptyNumber = field.type === "input-number" && value === null;
@@ -404,6 +405,8 @@ export const getDefaultValue = (field) => {
404
405
  case "select":
405
406
  case "segmented-control":
406
407
  return null;
408
+ case "tag-select":
409
+ return [];
407
410
  case "checkbox":
408
411
  return false;
409
412
  case "color-picker":
@@ -507,9 +510,10 @@ export const selectViewData = ({ state, props }) => {
507
510
  field._inputType = field.inputType || "text";
508
511
  }
509
512
 
510
- if (field.type === "select" || field.type === "segmented-control") {
513
+ if (field.type === "select" || field.type === "tag-select" || field.type === "segmented-control") {
511
514
  const val = get(state.formValues, field.name);
512
515
  field._selectedValue = val !== undefined ? val : null;
516
+ field._selectedValues = Array.isArray(val) ? val : [];
513
517
  field.placeholder = field.placeholder || "";
514
518
  // clearable defaults to true; noClear is the inverse
515
519
  field.noClear = field.clearable === false;
@@ -68,6 +68,8 @@ template:
68
68
  - rtgl-textarea#field${field._idx} data-field-name=${field.name} w=f rows=${field.rows} placeholder=${field.placeholder} ?disabled=${field._disabled}: null
69
69
  - $if field.type == "select":
70
70
  - rtgl-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} ?no-clear=${field.noClear} :selectedValue=${field._selectedValue} :placeholder=${field.placeholder} ?disabled=${field._disabled}: null
71
+ - $if field.type == "tag-select":
72
+ - rtgl-tag-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} :selectedValues=${field._selectedValues} :placeholder=${field.placeholder} :addOption=${field.addOption} ?disabled=${field._disabled}: null
71
73
  - $if field.type == "segmented-control":
72
74
  - rtgl-segmented-control#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} ?no-clear=${field.noClear} :selectedValue=${field._selectedValue} :placeholder=${field.placeholder} ?disabled=${field._disabled}: null
73
75
  - $if field.type == "color-picker":
@@ -0,0 +1,192 @@
1
+ const getOptionType = (option = {}) => {
2
+ if (option.type === "section") {
3
+ return "section";
4
+ }
5
+
6
+ if (option.type === "separator") {
7
+ return "separator";
8
+ }
9
+
10
+ return "item";
11
+ };
12
+
13
+ const normalizeSelectedValues = (selectedValues) => {
14
+ if (!Array.isArray(selectedValues)) {
15
+ return [];
16
+ }
17
+
18
+ return [...selectedValues];
19
+ };
20
+
21
+ const resolveCurrentValues = ({ store, props }) => {
22
+ if (store.selectHasSelectedValues()) {
23
+ return store.selectSelectedValues();
24
+ }
25
+
26
+ return normalizeSelectedValues(props.selectedValues);
27
+ };
28
+
29
+ const emitValueChange = ({
30
+ dispatchEvent,
31
+ value,
32
+ operation,
33
+ changedValue,
34
+ label,
35
+ index,
36
+ item,
37
+ }) => {
38
+ dispatchEvent(
39
+ new CustomEvent("value-change", {
40
+ detail: {
41
+ value,
42
+ operation,
43
+ changedValue,
44
+ label,
45
+ index,
46
+ item,
47
+ },
48
+ bubbles: true,
49
+ }),
50
+ );
51
+ };
52
+
53
+ export const handleBeforeMount = (deps) => {
54
+ const { store, props, render } = deps;
55
+
56
+ if (props.selectedValues !== undefined) {
57
+ store.updateSelectedValues({ values: props.selectedValues });
58
+ render();
59
+ }
60
+ };
61
+
62
+ export const handleOnUpdate = (deps, payload) => {
63
+ const { oldProps, newProps } = payload;
64
+ const { store, render } = deps;
65
+ let shouldRender = false;
66
+
67
+ if (!!newProps?.disabled && !oldProps?.disabled) {
68
+ store.closeOptionsPopover({});
69
+ shouldRender = true;
70
+ }
71
+
72
+ if (oldProps.selectedValues !== newProps.selectedValues) {
73
+ store.updateSelectedValues({ values: newProps.selectedValues, syncDraft: true });
74
+ shouldRender = true;
75
+ }
76
+
77
+ if (oldProps.options !== newProps.options) {
78
+ shouldRender = true;
79
+ }
80
+
81
+ if (shouldRender) {
82
+ render();
83
+ }
84
+ };
85
+
86
+ export const handleTriggerClick = (deps, payload) => {
87
+ const { store, render, refs, props } = deps;
88
+ if (props.disabled) return;
89
+
90
+ const event = payload._event;
91
+ event.stopPropagation();
92
+
93
+ const trigger = refs.trigger;
94
+ const rect = trigger.getBoundingClientRect();
95
+ const currentValues = resolveCurrentValues({ store, props });
96
+
97
+ store.openOptionsPopover({
98
+ position: {
99
+ x: Math.round(rect.left),
100
+ y: Math.round(rect.bottom + 12),
101
+ w: Math.max(Math.round(rect.width), 240),
102
+ },
103
+ values: currentValues,
104
+ });
105
+ render();
106
+ };
107
+
108
+ export const handleTriggerKeyDown = (deps, payload) => {
109
+ const event = payload._event;
110
+ if (event.key !== "Enter" && event.key !== " ") {
111
+ return;
112
+ }
113
+
114
+ event.preventDefault();
115
+ handleTriggerClick(deps, payload);
116
+ };
117
+
118
+ export const handlePopoverClose = (deps) => {
119
+ const { store, render } = deps;
120
+ store.closeOptionsPopover({});
121
+ render();
122
+ };
123
+
124
+ export const handleOptionClick = (deps, payload) => {
125
+ const { render, props, store } = deps;
126
+ if (props.disabled) return;
127
+
128
+ const event = payload._event;
129
+ event.stopPropagation();
130
+
131
+ const id = event.currentTarget.id.slice("option".length);
132
+ const index = Number(id);
133
+ const option = props.options[index];
134
+
135
+ if (!option || getOptionType(option) !== "item") {
136
+ return;
137
+ }
138
+
139
+ store.toggleDraftSelectedValue({ value: option.value });
140
+ render();
141
+ };
142
+
143
+ export const handleOptionKeyDown = (deps, payload) => {
144
+ const event = payload._event;
145
+ if (event.key !== "Enter" && event.key !== " ") {
146
+ return;
147
+ }
148
+
149
+ event.preventDefault();
150
+ handleOptionClick(deps, payload);
151
+ };
152
+
153
+ export const handleSubmitClick = (deps, payload) => {
154
+ const { store, props, render, dispatchEvent } = deps;
155
+ if (props.disabled) return;
156
+
157
+ const event = payload._event;
158
+ event.stopPropagation();
159
+
160
+ const nextValues = store.selectDraftSelectedValues();
161
+
162
+ store.commitDraftSelectedValues({});
163
+
164
+ if (props.onChange && typeof props.onChange === "function") {
165
+ props.onChange(nextValues);
166
+ }
167
+
168
+ emitValueChange({
169
+ dispatchEvent,
170
+ value: nextValues,
171
+ operation: "set",
172
+ changedValue: undefined,
173
+ label: undefined,
174
+ index: null,
175
+ item: undefined,
176
+ });
177
+
178
+ render();
179
+ };
180
+
181
+ export const handleAddOptionClick = (deps, payload) => {
182
+ const { props, dispatchEvent } = deps;
183
+ if (props.disabled) return;
184
+
185
+ const event = payload._event;
186
+ event.stopPropagation();
187
+
188
+ dispatchEvent(new CustomEvent("add-option-click", {
189
+ bubbles: true,
190
+ composed: true,
191
+ }));
192
+ };
@@ -0,0 +1,32 @@
1
+ const resolvePopoverPosition = (trigger) => {
2
+ if (!trigger || typeof trigger.getBoundingClientRect !== "function") {
3
+ return null;
4
+ }
5
+
6
+ const rect = trigger.getBoundingClientRect();
7
+ return {
8
+ x: Math.round(rect.left),
9
+ y: Math.round(rect.bottom + 12),
10
+ w: Math.max(Math.round(rect.width), 240),
11
+ };
12
+ };
13
+
14
+ export const refreshPopover = function () {
15
+ const state = this.store?.getState?.();
16
+
17
+ if (state?.isOpen) {
18
+ const position = resolvePopoverPosition(this.refs?.trigger);
19
+ const draftValues = this.store?.selectDraftSelectedValues?.() || [];
20
+
21
+ if (position) {
22
+ this.store.openOptionsPopover({
23
+ position,
24
+ values: draftValues,
25
+ });
26
+ }
27
+ }
28
+
29
+ if (typeof this.render === "function") {
30
+ this.render();
31
+ }
32
+ };
@@ -0,0 +1,51 @@
1
+ componentName: rtgl-tag-select
2
+ propsSchema:
3
+ type: object
4
+ properties:
5
+ placeholder:
6
+ type: string
7
+ options:
8
+ type: array
9
+ items:
10
+ type: object
11
+ properties:
12
+ type:
13
+ type: string
14
+ enum:
15
+ - section
16
+ - item
17
+ - separator
18
+ label:
19
+ type: string
20
+ value:
21
+ type: any
22
+ icon:
23
+ type: string
24
+ shortcut:
25
+ type: string
26
+ suffixText:
27
+ type: string
28
+ testId:
29
+ type: string
30
+ selectedValues:
31
+ type: array
32
+ items:
33
+ type: any
34
+ onChange:
35
+ type: function
36
+ addOption:
37
+ type: object
38
+ properties:
39
+ label:
40
+ type: string
41
+ disabled:
42
+ type: boolean
43
+ w:
44
+ type: string
45
+ events:
46
+ value-change: {}
47
+ add-option-click: {}
48
+ methods:
49
+ type: object
50
+ properties:
51
+ refreshPopover: {}
@@ -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
+ };