includio-cms 0.1.0 → 0.1.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/CHANGELOG.md +14 -0
- package/ROADMAP.md +4 -4
- package/dist/admin/components/fields/array-field.svelte +68 -22
- package/dist/admin/components/fields/field-renderer.svelte +25 -2
- package/dist/admin/components/fields/number-field.svelte +1 -1
- package/dist/admin/components/fields/text-field-wrapper.svelte +56 -1
- package/dist/admin/components/fields/text-field.svelte +2 -2
- package/dist/cms/runtime/types.d.ts +7 -0
- package/dist/core/fields/fieldSchemaToTs.js +18 -4
- package/dist/updates/0.1.1/index.d.ts +2 -0
- package/dist/updates/0.1.1/index.js +17 -0
- package/dist/updates/index.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.1.1 — 2026-02-18
|
|
7
|
+
|
|
8
|
+
Field constraint UI — visible limits, counters, and hints
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Text fields: character counter (X / Y) with aria-live, destructive color at limit
|
|
12
|
+
- Text fields: constraint hints (min/max chars, pattern format) in description
|
|
13
|
+
- Text fields: native minlength/maxlength HTML attributes on input/textarea
|
|
14
|
+
- Number fields: native min/max/step HTML attributes on input
|
|
15
|
+
- Number fields: range and step hints in description
|
|
16
|
+
- Array fields: items counter (X / Y) next to label when maxItems defined
|
|
17
|
+
- Array fields: Add/Duplicate buttons disabled at maxItems limit
|
|
18
|
+
- Array fields: fixed-length mode (minItems === maxItems) — pre-populated, reorder only
|
|
19
|
+
|
|
6
20
|
## 0.1.0 — 2026-02-17
|
|
7
21
|
|
|
8
22
|
Stabilization — pagination, language switcher, and more
|
package/ROADMAP.md
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
|
|
23
23
|
## 0.1.1 — Input integrity
|
|
24
24
|
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
25
|
+
- [x] `[fix]` `[P1]` Input constraints UI — HTML maxlength, character counter, pattern feedback <!-- files: src/lib/admin/components/fields/text-field.svelte, src/lib/admin/components/fields/text-field-wrapper.svelte -->
|
|
26
|
+
- [x] `[fix]` `[P1]` Array field maxItems — disable Add button when max reached <!-- files: src/lib/admin/components/fields/array-field.svelte -->
|
|
27
|
+
- [x] `[feature]` `[P1]` Array field fixed length — fixed item count, no add/remove, reorder only
|
|
28
|
+
- [x] `[feature]` `[P1]` Field constraint info display — show constraints before validation error (WCAG/ATAG) <!-- files: src/lib/admin/components/fields/text-field-wrapper.svelte, src/lib/admin/components/fields/field-renderer.svelte -->
|
|
29
29
|
|
|
30
30
|
## 0.1.2 — User management & RBAC
|
|
31
31
|
|
|
@@ -71,9 +71,28 @@
|
|
|
71
71
|
acc.push(item);
|
|
72
72
|
return acc;
|
|
73
73
|
}, [] as ObjectFieldData[]);
|
|
74
|
+
|
|
75
|
+
// Fixed-length: pad or trim to exact count
|
|
76
|
+
if (isFixedLength && $value.length !== fixedCount) {
|
|
77
|
+
const current = [...$value];
|
|
78
|
+
if (current.length < fixedCount) {
|
|
79
|
+
const defaults = field.defaultValue;
|
|
80
|
+
for (let i = current.length; i < fixedCount; i++) {
|
|
81
|
+
if (defaults && defaults[i]) {
|
|
82
|
+
current.push({ _id: generateId(), ...JSON.parse(JSON.stringify(defaults[i])) });
|
|
83
|
+
} else {
|
|
84
|
+
current.push({ _id: generateId(), slug: field.of[0].slug, data: {} });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
current.length = fixedCount;
|
|
89
|
+
}
|
|
90
|
+
$value = current;
|
|
91
|
+
}
|
|
74
92
|
});
|
|
75
93
|
|
|
76
94
|
async function addItem(field: ObjectFieldType) {
|
|
95
|
+
if (atMax) return;
|
|
77
96
|
$value = [
|
|
78
97
|
...($value ?? []),
|
|
79
98
|
{
|
|
@@ -87,7 +106,7 @@
|
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
function duplicateItem(index: number) {
|
|
90
|
-
if (!$value) return;
|
|
109
|
+
if (!$value || atMax) return;
|
|
91
110
|
const itemToDuplicate = $value[index];
|
|
92
111
|
if (!itemToDuplicate) return;
|
|
93
112
|
|
|
@@ -192,11 +211,28 @@
|
|
|
192
211
|
return normalizePath(flashingPath) === fieldPath;
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
const atMax = $derived(field.maxItems !== undefined && ($value?.length ?? 0) >= field.maxItems);
|
|
215
|
+
|
|
216
|
+
const isFixedLength = $derived(
|
|
217
|
+
field.minItems !== undefined &&
|
|
218
|
+
field.maxItems !== undefined &&
|
|
219
|
+
field.minItems === field.maxItems &&
|
|
220
|
+
field.minItems > 0
|
|
221
|
+
);
|
|
222
|
+
const fixedCount = $derived(isFixedLength ? field.minItems! : 0);
|
|
223
|
+
|
|
195
224
|
let blockPickerOpen = $state(false);
|
|
196
225
|
</script>
|
|
197
226
|
|
|
198
227
|
<div class="flex items-center justify-between gap-4">
|
|
199
|
-
<
|
|
228
|
+
<div class="flex items-center gap-2">
|
|
229
|
+
<RequiredLabel required={field.minItems !== undefined && field.minItems > 0} class="text-lg">{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
|
|
230
|
+
{#if isFixedLength}
|
|
231
|
+
<span class="text-xs text-muted-foreground">{fixedCount} elementów</span>
|
|
232
|
+
{:else if field.maxItems !== undefined}
|
|
233
|
+
<span class="text-xs {atMax ? 'text-destructive' : 'text-muted-foreground'}">{$value?.length ?? 0} / {field.maxItems}</span>
|
|
234
|
+
{/if}
|
|
235
|
+
</div>
|
|
200
236
|
|
|
201
237
|
<div class="flex items-center gap-2">
|
|
202
238
|
<Button
|
|
@@ -263,6 +299,7 @@
|
|
|
263
299
|
>
|
|
264
300
|
<div class="flex grow items-center justify-between gap-4">
|
|
265
301
|
<div class="flex items-center gap-4">
|
|
302
|
+
{#if !isFixedLength || fixedCount > 1}
|
|
266
303
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
267
304
|
<div
|
|
268
305
|
use:draggable={{ container: index.toString(), dragData: { id: item._id } }}
|
|
@@ -272,11 +309,13 @@
|
|
|
272
309
|
>
|
|
273
310
|
<GripVertical class="h-4 w-4" />
|
|
274
311
|
</div>
|
|
312
|
+
{/if}
|
|
275
313
|
<span>{index < 10 ? '0' : ''}{index + 1}</span>
|
|
276
314
|
<Badge variant="outline">{getLocalizedLabel(objectField.label, interfaceLanguage.current) || objectField.slug}</Badge>
|
|
277
315
|
<span>{getAccordionLabel($value[index])}</span>
|
|
278
316
|
</div>
|
|
279
317
|
|
|
318
|
+
{#if !(isFixedLength && fixedCount <= 1)}
|
|
280
319
|
<DropdownMenu.Root>
|
|
281
320
|
<DropdownMenu.Trigger
|
|
282
321
|
class="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
|
@@ -289,9 +328,11 @@
|
|
|
289
328
|
{/snippet}
|
|
290
329
|
</DropdownMenu.Trigger>
|
|
291
330
|
<DropdownMenu.Content align="end" class="w-32">
|
|
292
|
-
|
|
331
|
+
{#if !isFixedLength}
|
|
332
|
+
<DropdownMenu.Item onclick={() => duplicateItem(index)} disabled={atMax}>
|
|
293
333
|
Duplicate
|
|
294
334
|
</DropdownMenu.Item>
|
|
335
|
+
{/if}
|
|
295
336
|
<DropdownMenu.Item onclick={() => moveItemUp(index)} disabled={index === 0}>
|
|
296
337
|
Move up
|
|
297
338
|
</DropdownMenu.Item>
|
|
@@ -301,11 +342,14 @@
|
|
|
301
342
|
>
|
|
302
343
|
Move down
|
|
303
344
|
</DropdownMenu.Item>
|
|
345
|
+
{#if !isFixedLength}
|
|
304
346
|
<DropdownMenu.Item variant="destructive" onclick={() => removeItem(index)}>
|
|
305
347
|
Delete
|
|
306
348
|
</DropdownMenu.Item>
|
|
349
|
+
{/if}
|
|
307
350
|
</DropdownMenu.Content>
|
|
308
351
|
</DropdownMenu.Root>
|
|
352
|
+
{/if}
|
|
309
353
|
</div>
|
|
310
354
|
</Accordion.Trigger>
|
|
311
355
|
<Accordion.Content
|
|
@@ -341,25 +385,27 @@
|
|
|
341
385
|
{/if}
|
|
342
386
|
</Accordion.Root>
|
|
343
387
|
|
|
344
|
-
{#if
|
|
345
|
-
|
|
346
|
-
<
|
|
347
|
-
<
|
|
348
|
-
Add Block
|
|
349
|
-
</Button>
|
|
350
|
-
</div>
|
|
351
|
-
<BlockPickerModal
|
|
352
|
-
bind:open={blockPickerOpen}
|
|
353
|
-
options={field.of}
|
|
354
|
-
onSelect={(option) => addItem(option)}
|
|
355
|
-
/>
|
|
356
|
-
{:else}
|
|
357
|
-
<div class="mt-4 flex flex-wrap gap-2">
|
|
358
|
-
{#each field.of as option}
|
|
359
|
-
<Button size="sm" type="button" variant="outline" onclick={() => addItem(option)}>
|
|
388
|
+
{#if !isFixedLength}
|
|
389
|
+
{#if field.displayMode === 'blocks'}
|
|
390
|
+
<div class="mt-4">
|
|
391
|
+
<Button size="sm" type="button" variant="outline" disabled={atMax} onclick={() => (blockPickerOpen = true)}>
|
|
360
392
|
<CirclePlus />
|
|
361
|
-
|
|
393
|
+
Add Block
|
|
362
394
|
</Button>
|
|
363
|
-
|
|
364
|
-
|
|
395
|
+
</div>
|
|
396
|
+
<BlockPickerModal
|
|
397
|
+
bind:open={blockPickerOpen}
|
|
398
|
+
options={field.of}
|
|
399
|
+
onSelect={(option) => addItem(option)}
|
|
400
|
+
/>
|
|
401
|
+
{:else}
|
|
402
|
+
<div class="mt-4 flex flex-wrap gap-2">
|
|
403
|
+
{#each field.of as option}
|
|
404
|
+
<Button size="sm" type="button" variant="outline" disabled={atMax} onclick={() => addItem(option)}>
|
|
405
|
+
<CirclePlus />
|
|
406
|
+
{getLocalizedLabel(option.label, interfaceLanguage.current) || option.slug}
|
|
407
|
+
</Button>
|
|
408
|
+
{/each}
|
|
409
|
+
</div>
|
|
410
|
+
{/if}
|
|
365
411
|
{/if}
|
|
@@ -48,6 +48,23 @@
|
|
|
48
48
|
function isCheckboxesField(field: Field): field is Extract<Field, { type: 'checkboxes' }> {
|
|
49
49
|
return field.type === 'checkboxes';
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
function numberConstraintHint(f: Field): string {
|
|
53
|
+
if (f.type !== 'number') return '';
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
if (f.min !== undefined && f.max !== undefined) {
|
|
56
|
+
parts.push(`Zakres: ${f.min}–${f.max}`);
|
|
57
|
+
} else if (f.min !== undefined) {
|
|
58
|
+
parts.push(`Min. ${f.min}`);
|
|
59
|
+
} else if (f.max !== undefined) {
|
|
60
|
+
parts.push(`Maks. ${f.max}`);
|
|
61
|
+
}
|
|
62
|
+
if (f.step !== undefined) {
|
|
63
|
+
if (parts.length > 0) parts.push(` · `);
|
|
64
|
+
parts.push(`Krok: ${f.step}`);
|
|
65
|
+
}
|
|
66
|
+
return parts.join('');
|
|
67
|
+
}
|
|
51
68
|
</script>
|
|
52
69
|
|
|
53
70
|
{#if isRadioField(field)}
|
|
@@ -132,8 +149,14 @@
|
|
|
132
149
|
</div>
|
|
133
150
|
{/snippet}
|
|
134
151
|
</Form.Control>
|
|
135
|
-
{#if !fieldsWithNoDescription.includes(field.type) && !fieldsWithAlternativeDescription.includes(field.type) && field.description}
|
|
136
|
-
<Form.Description>
|
|
152
|
+
{#if !fieldsWithNoDescription.includes(field.type) && !fieldsWithAlternativeDescription.includes(field.type) && (field.description || numberConstraintHint(field))}
|
|
153
|
+
<Form.Description>
|
|
154
|
+
{#if field.description}{getLocalizedLabel(field.description, interfaceLanguage.current)}{/if}
|
|
155
|
+
{#if numberConstraintHint(field)}
|
|
156
|
+
{#if field.description}<br />{/if}
|
|
157
|
+
{numberConstraintHint(field)}
|
|
158
|
+
{/if}
|
|
159
|
+
</Form.Description>
|
|
137
160
|
{/if}
|
|
138
161
|
<Form.FieldErrors />
|
|
139
162
|
</Form.Field>
|
|
@@ -27,6 +27,30 @@
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
let { field, form, path, ...props }: Props = $props();
|
|
30
|
+
|
|
31
|
+
const formData = form.form;
|
|
32
|
+
|
|
33
|
+
const isText = $derived(field.type === 'text');
|
|
34
|
+
const hasConstraints = $derived(
|
|
35
|
+
isText && (field.minLength !== undefined || field.maxLength !== undefined || field.pattern !== undefined)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function resolvePathValue(data: Record<string, unknown>, dotPath: string): unknown {
|
|
39
|
+
return dotPath.split('.').reduce<unknown>((obj, key) => (obj as Record<string, unknown>)?.[key], data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function constraintHint(): string {
|
|
43
|
+
if (field.type !== 'text') return '';
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
if (field.minLength !== undefined && field.maxLength !== undefined) {
|
|
46
|
+
parts.push(`${field.minLength}–${field.maxLength} znaków`);
|
|
47
|
+
} else if (field.minLength !== undefined) {
|
|
48
|
+
parts.push(`Min. ${field.minLength} znaków`);
|
|
49
|
+
} else if (field.maxLength !== undefined) {
|
|
50
|
+
parts.push(`Maks. ${field.maxLength} znaków`);
|
|
51
|
+
}
|
|
52
|
+
return parts.join('');
|
|
53
|
+
}
|
|
30
54
|
</script>
|
|
31
55
|
|
|
32
56
|
<Tabs.Root
|
|
@@ -66,7 +90,38 @@
|
|
|
66
90
|
{/snippet}
|
|
67
91
|
</Form.Control>
|
|
68
92
|
|
|
69
|
-
|
|
93
|
+
{#if isText}
|
|
94
|
+
{@const val = resolvePathValue($formData, joinPath(path, lang))}
|
|
95
|
+
{@const charCount = typeof val === 'string' ? val.length : 0}
|
|
96
|
+
{@const atLimit = field.type === 'text' && field.maxLength !== undefined && charCount >= field.maxLength}
|
|
97
|
+
<div class="flex items-start justify-between gap-4">
|
|
98
|
+
{#if field.description || hasConstraints}
|
|
99
|
+
<Form.Description class="flex-1">
|
|
100
|
+
{#if field.description}{getLocalizedLabel(field.description, interfaceLanguage.current)}{/if}
|
|
101
|
+
{#if hasConstraints}
|
|
102
|
+
{#if field.description && constraintHint()}<br />{/if}
|
|
103
|
+
{#if constraintHint()}{constraintHint()}{/if}
|
|
104
|
+
{#if field.type === 'text' && field.pattern}
|
|
105
|
+
{#if constraintHint()} · {/if}Format: <code class="text-xs bg-muted px-1 py-0.5 rounded">{field.pattern}</code>
|
|
106
|
+
{/if}
|
|
107
|
+
{/if}
|
|
108
|
+
</Form.Description>
|
|
109
|
+
{:else}
|
|
110
|
+
<div></div>
|
|
111
|
+
{/if}
|
|
112
|
+
<span class="shrink-0 text-xs {atLimit ? 'text-destructive' : 'text-muted-foreground'}" aria-live="polite">
|
|
113
|
+
{#if field.type === 'text' && field.maxLength !== undefined}
|
|
114
|
+
{charCount} / {field.maxLength}
|
|
115
|
+
{:else if field.type === 'text' && field.minLength !== undefined}
|
|
116
|
+
{charCount} (min. {field.minLength})
|
|
117
|
+
{:else}
|
|
118
|
+
{charCount}
|
|
119
|
+
{/if}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
{:else if field.description}
|
|
123
|
+
<Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
|
|
124
|
+
{/if}
|
|
70
125
|
|
|
71
126
|
<Form.FieldErrors />
|
|
72
127
|
</Form.Field>
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
</script>
|
|
36
36
|
|
|
37
37
|
{#if field.multiline}
|
|
38
|
-
<Textarea {...props} bind:value={$value} placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} />
|
|
38
|
+
<Textarea {...props} bind:value={$value} placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} minlength={field.minLength} maxlength={field.maxLength} />
|
|
39
39
|
{:else}
|
|
40
|
-
<Input {...props} bind:value={$value} type="text" placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} />
|
|
40
|
+
<Input {...props} bind:value={$value} type="text" placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} minlength={field.minLength} maxlength={field.maxLength} />
|
|
41
41
|
{/if}
|
|
@@ -74,10 +74,24 @@ export function generateZodSchemaFromField(field, languages, options = {
|
|
|
74
74
|
? z.discriminatedUnion('slug', schemas)
|
|
75
75
|
: schemas[0];
|
|
76
76
|
let schema = z.array(itemSchema);
|
|
77
|
-
if (field.minItems !== undefined
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
if (field.minItems !== undefined &&
|
|
78
|
+
field.maxItems !== undefined &&
|
|
79
|
+
field.minItems === field.maxItems &&
|
|
80
|
+
field.minItems > 0) {
|
|
81
|
+
schema = schema.length(field.minItems, {
|
|
82
|
+
message: `Wymagane dokładnie ${field.minItems} elementów`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (field.minItems !== undefined)
|
|
87
|
+
schema = schema.min(field.minItems, {
|
|
88
|
+
message: `Minimum ${field.minItems} elementów`
|
|
89
|
+
});
|
|
90
|
+
if (field.maxItems !== undefined)
|
|
91
|
+
schema = schema.max(field.maxItems, {
|
|
92
|
+
message: `Maksimum ${field.maxItems} elementów`
|
|
93
|
+
});
|
|
94
|
+
}
|
|
81
95
|
return schema.default([]); // Default to empty array if not required
|
|
82
96
|
}
|
|
83
97
|
case 'slug': {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.1.1',
|
|
3
|
+
date: '2026-02-18',
|
|
4
|
+
description: 'Field constraint UI — visible limits, counters, and hints',
|
|
5
|
+
features: [
|
|
6
|
+
'Text fields: character counter (X / Y) with aria-live, destructive color at limit',
|
|
7
|
+
'Text fields: constraint hints (min/max chars, pattern format) in description',
|
|
8
|
+
'Text fields: native minlength/maxlength HTML attributes on input/textarea',
|
|
9
|
+
'Number fields: native min/max/step HTML attributes on input',
|
|
10
|
+
'Number fields: range and step hints in description',
|
|
11
|
+
'Array fields: items counter (X / Y) next to label when maxItems defined',
|
|
12
|
+
'Array fields: Add/Duplicate buttons disabled at maxItems limit',
|
|
13
|
+
'Array fields: fixed-length mode (minItems === maxItems) — pre-populated, reorder only'
|
|
14
|
+
],
|
|
15
|
+
fixes: [],
|
|
16
|
+
breakingChanges: []
|
|
17
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import { update as update0067 } from './0.0.67/index.js';
|
|
|
4
4
|
import { update as update0068 } from './0.0.68/index.js';
|
|
5
5
|
import { update as update0069 } from './0.0.69/index.js';
|
|
6
6
|
import { update as update010 } from './0.1.0/index.js';
|
|
7
|
-
|
|
7
|
+
import { update as update011 } from './0.1.1/index.js';
|
|
8
|
+
export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011];
|
|
8
9
|
export const getUpdatesFrom = (fromVersion) => {
|
|
9
10
|
const fromParts = fromVersion.split('.').map(Number);
|
|
10
11
|
return updates.filter((update) => {
|