@rettangoli/ui 1.0.0-rc13 → 1.0.0-rc14

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.
@@ -15,35 +15,74 @@ const encode = (input) => {
15
15
  return ""
16
16
  }
17
17
  return `"${escapeHtml(String(input))}"`;
18
+ };
19
+
20
+ const isObjectLike = (value) => value !== null && typeof value === "object";
21
+ const isPlainObject = (value) => isObjectLike(value) && !Array.isArray(value);
22
+ const isPathLike = (path) => typeof path === "string" && path.includes(".");
23
+ const hasBracketPathToken = (path) => typeof path === "string" && /[\[\]]/.test(path);
24
+
25
+ function pickByPaths(obj, paths) {
26
+ const result = {};
27
+ for (const path of paths) {
28
+ if (typeof path !== "string" || path.length === 0) continue;
29
+ const value = get(obj, path);
30
+ if (value !== undefined) {
31
+ set(result, path, value);
32
+ }
33
+ }
34
+ return result;
18
35
  }
19
36
 
20
- function pick(obj, keys) {
21
- return keys.reduce((acc, key) => {
22
- if (key in obj) acc[key] = obj[key];
23
- return acc;
24
- }, {});
37
+ function normalizeWhenDirectives(form) {
38
+ if (!isPlainObject(form) || !Array.isArray(form.fields)) {
39
+ return form;
40
+ }
41
+
42
+ const normalizeFields = (fields = []) =>
43
+ fields.map((field) => {
44
+ if (!isPlainObject(field)) {
45
+ return field;
46
+ }
47
+
48
+ if (typeof field.$when === "string" && field.$when.trim().length > 0) {
49
+ const { $when, ...rest } = field;
50
+ const normalizedField = Array.isArray(rest.fields)
51
+ ? { ...rest, fields: normalizeFields(rest.fields) }
52
+ : rest;
53
+ return {
54
+ [`$if ${$when}`]: normalizedField,
55
+ };
56
+ }
57
+
58
+ if (Array.isArray(field.fields)) {
59
+ return {
60
+ ...field,
61
+ fields: normalizeFields(field.fields),
62
+ };
63
+ }
64
+
65
+ return field;
66
+ });
67
+
68
+ return {
69
+ ...form,
70
+ fields: normalizeFields(form.fields),
71
+ };
25
72
  }
26
73
 
27
- export const createInitialState = () => Object.freeze({
28
- formValues: {},
29
- tooltipState: {
30
- open: false,
31
- x: 0,
32
- y: 0,
33
- content: ''
34
- },
35
- });
36
-
37
- // Lodash-like utility functions for nested property access
38
- const get = (obj, path, defaultValue = undefined) => {
39
- if (!path) {
40
- return;
41
- }
42
- const keys = path.split(/[\[\].]/).filter((key) => key !== "");
74
+ // Nested property access utilities
75
+ export const get = (obj, path, defaultValue = undefined) => {
76
+ if (!path) return defaultValue;
77
+ if (!isObjectLike(obj)) return defaultValue;
78
+ if (hasBracketPathToken(path)) return defaultValue;
79
+ const keys = path.split(".").filter((key) => key !== "");
43
80
  let current = obj;
44
-
45
81
  for (const key of keys) {
46
82
  if (current === null || current === undefined || !(key in current)) {
83
+ if (Object.prototype.hasOwnProperty.call(obj, path)) {
84
+ return obj[path];
85
+ }
47
86
  return defaultValue;
48
87
  }
49
88
  current = current[key];
@@ -51,16 +90,21 @@ const get = (obj, path, defaultValue = undefined) => {
51
90
  return current;
52
91
  };
53
92
 
54
- const set = (obj, path, value) => {
55
- const keys = path.split(/[\[\].]/).filter((key) => key !== "");
56
-
57
- // If path contains array notation, delete the original flat key
58
- if (path.includes("[") && path in obj) {
93
+ export const set = (obj, path, value) => {
94
+ if (!isObjectLike(obj) || typeof path !== "string" || path.length === 0) {
95
+ return obj;
96
+ }
97
+ if (hasBracketPathToken(path)) {
98
+ return obj;
99
+ }
100
+ const keys = path.split(".").filter((key) => key !== "");
101
+ if (keys.length === 0) {
102
+ return obj;
103
+ }
104
+ if (isPathLike(path) && Object.prototype.hasOwnProperty.call(obj, path)) {
59
105
  delete obj[path];
60
106
  }
61
-
62
107
  let current = obj;
63
-
64
108
  for (let i = 0; i < keys.length - 1; i++) {
65
109
  const key = keys[i];
66
110
  if (
@@ -68,19 +112,15 @@ const set = (obj, path, value) => {
68
112
  typeof current[key] !== "object" ||
69
113
  current[key] === null
70
114
  ) {
71
- // Check if next key is a number to create array
72
- const nextKey = keys[i + 1];
73
- const isArrayIndex = /^\d+$/.test(nextKey);
74
- current[key] = isArrayIndex ? [] : {};
115
+ current[key] = {};
75
116
  }
76
117
  current = current[key];
77
118
  }
78
-
79
119
  current[keys[keys.length - 1]] = value;
80
120
  return obj;
81
121
  };
82
122
 
83
- const blacklistedAttrs = ["id", "class", "style", "slot", "form", "defaultValues", "context", "autofocus", "key"];
123
+ const blacklistedAttrs = ["id", "class", "style", "slot", "form", "defaultValues", "disabled"];
84
124
 
85
125
  const stringifyAttrs = (props = {}) => {
86
126
  return Object.entries(props)
@@ -89,100 +129,363 @@ const stringifyAttrs = (props = {}) => {
89
129
  .join(" ");
90
130
  };
91
131
 
92
- export const selectForm = ({ props }) => {
93
- const { form = {} } = props;
94
- const { context } = props;
132
+ // --- Validation ---
133
+
134
+ const PATTERN_PRESETS = {
135
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
136
+ url: /^https?:\/\/.+/,
137
+ };
138
+
139
+ const DEFAULT_MESSAGES = {
140
+ required: "This field is required",
141
+ minLength: (val) => `Must be at least ${val} characters`,
142
+ maxLength: (val) => `Must be at most ${val} characters`,
143
+ pattern: "Invalid format",
144
+ };
145
+
146
+ export const validateField = (field, value) => {
147
+ // Check required
148
+ if (field.required) {
149
+ const isEmpty =
150
+ value === undefined ||
151
+ value === null ||
152
+ value === "" ||
153
+ (typeof value === "boolean" && value === false);
154
+ // For numbers, 0 is a valid value
155
+ const isEmptyNumber = field.type === "input-number" && value === null;
156
+ const shouldFail = field.type === "input-number" ? isEmptyNumber : isEmpty;
157
+
158
+ if (shouldFail) {
159
+ if (typeof field.required === "object" && field.required.message) {
160
+ return field.required.message;
161
+ }
162
+ return DEFAULT_MESSAGES.required;
163
+ }
164
+ }
165
+
166
+ // Check rules
167
+ if (Array.isArray(field.rules)) {
168
+ for (const rule of field.rules) {
169
+ const error = validateRule(rule, value);
170
+ if (error) return error;
171
+ }
172
+ }
173
+
174
+ return null;
175
+ };
176
+
177
+ const validateRule = (rule, value) => {
178
+ // Skip validation on empty values (required handles that)
179
+ if (value === undefined || value === null || value === "") return null;
180
+
181
+ const strValue = String(value);
182
+
183
+ switch (rule.rule) {
184
+ case "minLength": {
185
+ if (strValue.length < rule.value) {
186
+ return rule.message || DEFAULT_MESSAGES.minLength(rule.value);
187
+ }
188
+ return null;
189
+ }
190
+ case "maxLength": {
191
+ if (strValue.length > rule.value) {
192
+ return rule.message || DEFAULT_MESSAGES.maxLength(rule.value);
193
+ }
194
+ return null;
195
+ }
196
+ case "pattern": {
197
+ const preset = PATTERN_PRESETS[rule.value];
198
+ let regex = preset;
199
+ if (!regex) {
200
+ try {
201
+ regex = new RegExp(rule.value);
202
+ } catch {
203
+ return rule.message || DEFAULT_MESSAGES.pattern;
204
+ }
205
+ }
206
+ if (!regex.test(strValue)) {
207
+ return rule.message || DEFAULT_MESSAGES.pattern;
208
+ }
209
+ return null;
210
+ }
211
+ default:
212
+ return null;
213
+ }
214
+ };
215
+
216
+ export const validateForm = (fields, formValues) => {
217
+ const errors = {};
218
+ const dataFields = collectAllDataFields(fields);
219
+
220
+ for (const field of dataFields) {
221
+ const value = get(formValues, field.name);
222
+ const error = validateField(field, value);
223
+ if (error) {
224
+ errors[field.name] = error;
225
+ }
226
+ }
227
+
228
+ return {
229
+ valid: Object.keys(errors).length === 0,
230
+ errors,
231
+ };
232
+ };
233
+
234
+ // --- Field helpers ---
235
+
236
+ const DISPLAY_TYPES = ["section", "read-only-text", "slot"];
237
+
238
+ export const isDataField = (field) => {
239
+ return !DISPLAY_TYPES.includes(field.type);
240
+ };
241
+
242
+ export const collectAllDataFields = (fields) => {
243
+ const result = [];
244
+ for (const field of fields) {
245
+ if (field.type === "section" && Array.isArray(field.fields)) {
246
+ result.push(...collectAllDataFields(field.fields));
247
+ } else if (isDataField(field)) {
248
+ result.push(field);
249
+ }
250
+ }
251
+ return result;
252
+ };
253
+
254
+ export const getDefaultValue = (field) => {
255
+ switch (field.type) {
256
+ case "input-text":
257
+ case "input-textarea":
258
+ case "popover-input":
259
+ return "";
260
+ case "input-number":
261
+ return null;
262
+ case "select":
263
+ return null;
264
+ case "checkbox":
265
+ return false;
266
+ case "color-picker":
267
+ return "#000000";
268
+ case "slider":
269
+ case "slider-with-input":
270
+ return field.min !== undefined ? field.min : 0;
271
+ case "image":
272
+ return null;
273
+ default:
274
+ return null;
275
+ }
276
+ };
277
+
278
+ export const flattenFields = (fields, startIdx = 0) => {
279
+ const result = [];
280
+ let idx = startIdx;
95
281
 
96
- if (context) {
97
- const result = parseAndRender(form, context);
98
- return result
282
+ for (const field of fields) {
283
+ if (field.type === "section") {
284
+ result.push({
285
+ ...field,
286
+ _isSection: true,
287
+ _idx: idx,
288
+ });
289
+ idx++;
290
+ if (Array.isArray(field.fields)) {
291
+ const nested = flattenFields(field.fields, idx);
292
+ result.push(...nested);
293
+ idx += nested.length;
294
+ }
295
+ } else {
296
+ result.push({
297
+ ...field,
298
+ _isSection: false,
299
+ _idx: idx,
300
+ });
301
+ idx++;
302
+ }
99
303
  }
100
304
 
101
- return form;
305
+ return result;
102
306
  };
103
307
 
308
+ // --- Store ---
309
+
310
+ export const createInitialState = () =>
311
+ Object.freeze({
312
+ formValues: {},
313
+ errors: {},
314
+ reactiveMode: false,
315
+ tooltipState: {
316
+ open: false,
317
+ x: 0,
318
+ y: 0,
319
+ content: "",
320
+ },
321
+ });
322
+
323
+ export const selectForm = ({ state, props }) => {
324
+ const { form = {} } = props || {};
325
+ const normalizedForm = normalizeWhenDirectives(form);
326
+ const context = isPlainObject(props?.context) ? props.context : {};
327
+ const stateFormValues = isPlainObject(state?.formValues)
328
+ ? state.formValues
329
+ : {};
330
+ const mergedContext = {
331
+ ...context,
332
+ ...stateFormValues,
333
+ formValues: stateFormValues,
334
+ };
335
+
336
+ if (Object.keys(mergedContext).length > 0) {
337
+ return parseAndRender(normalizedForm, mergedContext);
338
+ }
339
+ return normalizedForm;
340
+ };
104
341
 
105
342
  export const selectViewData = ({ state, props }) => {
106
343
  const containerAttrString = stringifyAttrs(props);
107
-
108
344
  const form = selectForm({ state, props });
109
- const fields = structuredClone(form.fields || []);
110
- fields.forEach((field) => {
111
- // Use formValues from state if available, otherwise fall back to defaultValues from props
112
- const defaultValue = get(state.formValues, field.name)
113
- if (["read-only-text"].includes(field.inputType)) {
114
- field.defaultValue = defaultValue
115
- }
116
- if (["select"].includes(field.inputType)) {
117
- const defaultValues = props?.defaultValues;
118
- if (defaultValues && defaultValues[field.name] !== undefined) {
119
- field.selectedValue = defaultValues[field.name];
120
- }
345
+ const fields = form.fields || [];
346
+ const formDisabled = !!props?.disabled;
347
+
348
+ // Flatten fields for template iteration
349
+ const flatFields = flattenFields(fields);
350
+
351
+ // Enrich each field with computed properties
352
+ flatFields.forEach((field, arrIdx) => {
353
+ field._arrIdx = arrIdx;
354
+
355
+ if (field._isSection) return;
356
+
357
+ const isData = isDataField(field);
358
+ field._disabled = formDisabled || !!field.disabled;
359
+
360
+ if (isData && field.name) {
361
+ field._error = state.errors[field.name] || null;
362
+ }
363
+
364
+ // Type-specific computed props
365
+ if (field.type === "input-text") {
366
+ field._inputType = field.inputType || "text";
367
+ }
368
+
369
+ if (field.type === "select") {
370
+ const val = get(state.formValues, field.name);
371
+ field._selectedValue = val !== undefined ? val : null;
372
+ field.placeholder = field.placeholder || "";
373
+ // clearable defaults to true; noClear is the inverse
374
+ field.noClear = field.clearable === false;
375
+ }
376
+
377
+ if (field.type === "image") {
378
+ const src = get(state.formValues, field.name);
379
+ field._imageSrc = src && String(src).trim() ? src : null;
380
+ field.placeholderText = field.placeholderText || "No Image";
121
381
  }
122
- if (field.inputType === "image") {
123
- const src = field.src;
124
- // Only set imageSrc if src exists and is not empty
125
- field.imageSrc = src && src.trim() ? src : null;
126
- // Set placeholder text
127
- field.placeholderText = field.placeholder || "No Image";
382
+
383
+ if (field.type === "read-only-text") {
384
+ field.content = field.content || "";
128
385
  }
129
- if (field.inputType === "waveform") {
130
- const waveformData = field.waveformData;
131
- // Only set waveformData if it exists
132
- field.waveformData = waveformData || null;
133
- // Set placeholder text
134
- field.placeholderText = field.placeholder || "No Waveform";
386
+
387
+ if (field.type === "checkbox") {
388
+ const inlineText = typeof field.content === "string"
389
+ ? field.content
390
+ : (typeof field.checkboxLabel === "string" ? field.checkboxLabel : "");
391
+ field._checkboxText = inlineText;
135
392
  }
136
393
  });
137
394
 
395
+ // Actions
396
+ const actions = form.actions || { buttons: [] };
397
+ const layout = actions.layout || "split";
398
+ const buttons = (actions.buttons || []).map((btn, i) => ({
399
+ ...btn,
400
+ _globalIdx: i,
401
+ variant: btn.variant || "se",
402
+ _disabled: formDisabled || !!btn.disabled,
403
+ pre: btn.pre || "",
404
+ suf: btn.suf || "",
405
+ }));
406
+
407
+ let actionsData;
408
+ if (layout === "split") {
409
+ actionsData = {
410
+ _layout: "split",
411
+ buttons,
412
+ _leftButtons: buttons.filter((b) => b.align === "left"),
413
+ _rightButtons: buttons.filter((b) => b.align !== "left"),
414
+ };
415
+ } else {
416
+ actionsData = {
417
+ _layout: layout,
418
+ buttons,
419
+ _allButtons: buttons,
420
+ };
421
+ }
422
+
138
423
  return {
139
- key: props?.key,
140
424
  containerAttrString,
141
425
  title: form?.title || "",
142
426
  description: form?.description || "",
143
- fields: fields,
144
- actions: props?.form?.actions || {
145
- buttons: [],
146
- },
427
+ flatFields,
428
+ actions: actionsData,
147
429
  formValues: state.formValues,
148
430
  tooltipState: state.tooltipState,
149
431
  };
150
432
  };
151
433
 
152
- export const selectState = ({ state }) => {
153
- return state;
154
- };
155
-
156
434
  export const selectFormValues = ({ state, props }) => {
157
435
  const form = selectForm({ state, props });
158
-
159
- return pick(
436
+ const dataFields = collectAllDataFields(form.fields || []);
437
+ return pickByPaths(
160
438
  state.formValues,
161
- form.fields.map((field) => field.name),
439
+ dataFields.map((f) => f.name).filter((name) => typeof name === "string" && name.length > 0),
162
440
  );
163
441
  };
164
442
 
165
- export const getFormFieldValue = ({ state }, name) => {
166
- return get(state.formValues, name);
167
- };
168
-
169
- export const setFormValues = ({ state }, payload = {}) => {
170
- state.formValues = payload.formValues || {};
171
- };
172
-
173
443
  export const setFormFieldValue = ({ state, props }, payload = {}) => {
174
444
  const { name, value } = payload;
175
- if (!name) {
176
- return;
177
- }
445
+ if (!name) return;
178
446
  set(state.formValues, name, value);
179
- // remove non visible values
447
+ pruneHiddenValues({ state, props });
448
+ };
449
+
450
+ export const pruneHiddenValues = ({ state, props }) => {
451
+ if (!props) return;
452
+ // Prune to only visible field names
180
453
  const form = selectForm({ state, props });
181
- const formValues = pick(
454
+ const dataFields = collectAllDataFields(form.fields || []);
455
+ state.formValues = pickByPaths(
182
456
  state.formValues,
183
- form.fields.map((field) => field.name),
457
+ dataFields.map((f) => f.name).filter((name) => typeof name === "string" && name.length > 0),
184
458
  );
185
- state.formValues = formValues;
459
+ };
460
+
461
+ export const setFormValues = ({ state }, payload = {}) => {
462
+ const { values } = payload;
463
+ if (!values || typeof values !== "object") return;
464
+ Object.keys(values).forEach((key) => {
465
+ set(state.formValues, key, values[key]);
466
+ });
467
+ };
468
+
469
+ export const resetFormValues = ({ state }, payload = {}) => {
470
+ const { defaultValues = {} } = payload;
471
+ state.formValues = defaultValues ? structuredClone(defaultValues) : {};
472
+ state.errors = {};
473
+ state.reactiveMode = false;
474
+ };
475
+
476
+ export const setErrors = ({ state }, payload = {}) => {
477
+ state.errors = payload.errors || {};
478
+ };
479
+
480
+ export const clearFieldError = ({ state }, payload = {}) => {
481
+ const { name } = payload;
482
+ if (name && state.errors[name]) {
483
+ delete state.errors[name];
484
+ }
485
+ };
486
+
487
+ export const setReactiveMode = ({ state }) => {
488
+ state.reactiveMode = true;
186
489
  };
187
490
 
188
491
  export const showTooltip = ({ state }, payload = {}) => {
@@ -198,6 +501,6 @@ export const showTooltip = ({ state }, payload = {}) => {
198
501
  export const hideTooltip = ({ state }) => {
199
502
  state.tooltipState = {
200
503
  ...state.tooltipState,
201
- open: false
504
+ open: false,
202
505
  };
203
506
  };