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 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
- - [ ] `[fix]` `[P1]` Input constraints UI — HTML maxlength, character counter, pattern feedback <!-- files: src/lib/admin/components/fields/text-field.svelte -->
26
- - [ ] `[fix]` `[P1]` Array field maxItems — disable Add button when max reached <!-- files: src/lib/admin/components/fields/array-field.svelte -->
27
- - [ ] `[feature]` `[P1]` Array field fixed length — fixed item count, no add/remove, reorder only
28
- - [ ] `[feature]` `[P1]` Field constraint info display — show constraints before validation error (WCAG/ATAG)
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
- <RequiredLabel required={field.minItems !== undefined && field.minItems > 0} class="text-lg">{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
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
- <DropdownMenu.Item onclick={() => duplicateItem(index)}>
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 field.displayMode === 'blocks'}
345
- <div class="mt-4">
346
- <Button size="sm" type="button" variant="outline" onclick={() => (blockPickerOpen = true)}>
347
- <CirclePlus />
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
- {getLocalizedLabel(option.label, interfaceLanguage.current) || option.slug}
393
+ Add Block
362
394
  </Button>
363
- {/each}
364
- </div>
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>{getLocalizedLabel(field.description, interfaceLanguage.current)}</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>
@@ -31,4 +31,4 @@
31
31
  });
32
32
  </script>
33
33
 
34
- <Input {...props} bind:value={$value} type="number" />
34
+ <Input {...props} bind:value={$value} type="number" min={field.min} max={field.max} step={field.step} />
@@ -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
- <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
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}
@@ -35,6 +35,13 @@ export interface BlogPost {
35
35
  slug: string;
36
36
  data: {
37
37
  title: string;
38
+ rating: number;
39
+ test?: ({
40
+ slug: 'test-object';
41
+ data: {
42
+ dupa: string;
43
+ };
44
+ })[];
38
45
  slug?: string;
39
46
  seo: {
40
47
  slug?: string;
@@ -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
- schema = schema.min(field.minItems, { message: `Minimum ${field.minItems} elementów` });
79
- if (field.maxItems !== undefined)
80
- schema = schema.max(field.maxItems, { message: `Maksimum ${field.maxItems} elementów` });
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,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -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
+ };
@@ -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
- export const updates = [update0065, update0066, update0067, update0068, update0069, update010];
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",