@rokkit/forms 1.0.0-next.124 → 1.0.0-next.127

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.
Files changed (89) hide show
  1. package/README.md +251 -0
  2. package/dist/src/display/index.d.ts +5 -0
  3. package/dist/src/index.d.ts +9 -0
  4. package/dist/src/input/index.d.ts +3 -0
  5. package/dist/src/lib/builder.svelte.d.ts +114 -4
  6. package/dist/src/lib/lookup.svelte.d.ts +87 -0
  7. package/dist/src/lib/renderers.d.ts +23 -0
  8. package/package.json +6 -4
  9. package/src/FieldLayout.svelte +4 -11
  10. package/src/FormRenderer.svelte +202 -61
  11. package/src/InfoField.svelte +26 -0
  12. package/src/Input.svelte +17 -61
  13. package/src/InputField.svelte +15 -11
  14. package/src/ValidationReport.svelte +52 -0
  15. package/src/display/DisplayCardGrid.svelte +68 -0
  16. package/src/display/DisplayList.svelte +31 -0
  17. package/src/display/DisplaySection.svelte +20 -0
  18. package/src/display/DisplayTable.svelte +68 -0
  19. package/src/display/DisplayValue.svelte +44 -0
  20. package/src/display/index.js +5 -0
  21. package/src/index.js +14 -0
  22. package/src/input/ArrayEditor.svelte +108 -0
  23. package/src/input/InputCheckbox.svelte +2 -3
  24. package/src/input/InputColor.svelte +6 -1
  25. package/src/input/InputDate.svelte +6 -1
  26. package/src/input/InputDateTime.svelte +6 -1
  27. package/src/input/InputEmail.svelte +6 -1
  28. package/src/input/InputFile.svelte +6 -2
  29. package/src/input/InputMonth.svelte +6 -1
  30. package/src/input/InputNumber.svelte +6 -1
  31. package/src/input/InputPassword.svelte +6 -1
  32. package/src/input/InputRange.svelte +6 -1
  33. package/src/input/InputSelect.svelte +31 -53
  34. package/src/input/InputSwitch.svelte +4 -15
  35. package/src/input/InputTel.svelte +6 -1
  36. package/src/input/InputText.svelte +6 -1
  37. package/src/input/InputTextArea.svelte +6 -1
  38. package/src/input/InputTime.svelte +6 -1
  39. package/src/input/InputToggle.svelte +28 -0
  40. package/src/input/InputUrl.svelte +6 -1
  41. package/src/input/InputWeek.svelte +6 -1
  42. package/src/input/index.js +3 -1
  43. package/src/lib/Input.svelte +3 -3
  44. package/src/lib/builder.svelte.js +425 -30
  45. package/src/lib/fields.js +2 -2
  46. package/src/lib/layout.js +2 -2
  47. package/src/lib/lookup.svelte.js +334 -0
  48. package/src/lib/renderers.js +83 -0
  49. package/src/lib/schema.js +1 -1
  50. package/src/types.js +0 -9
  51. package/dist/src/forms-old/input/types.d.ts +0 -7
  52. package/dist/src/forms-old/lib/form.d.ts +0 -95
  53. package/dist/src/forms-old/lib/index.d.ts +0 -1
  54. package/dist/src/lib/deprecated/nested.d.ts +0 -48
  55. package/dist/src/lib/deprecated/nested.spec.d.ts +0 -1
  56. package/dist/src/lib/deprecated/validator.d.ts +0 -30
  57. package/dist/src/lib/deprecated/validator.spec.d.ts +0 -1
  58. package/src/DataEditor.svelte +0 -30
  59. package/src/ListEditor.svelte +0 -44
  60. package/src/NestedEditor.svelte +0 -85
  61. package/src/forms-old/CheckBox.svelte +0 -56
  62. package/src/forms-old/DataEditor.svelte +0 -30
  63. package/src/forms-old/FieldLayout.svelte +0 -48
  64. package/src/forms-old/Form.svelte +0 -17
  65. package/src/forms-old/Icon.svelte +0 -76
  66. package/src/forms-old/Item.svelte +0 -25
  67. package/src/forms-old/ListEditor.svelte +0 -44
  68. package/src/forms-old/Tabs.svelte +0 -57
  69. package/src/forms-old/Wrapper.svelte +0 -12
  70. package/src/forms-old/input/Input.svelte +0 -17
  71. package/src/forms-old/input/InputField.svelte +0 -70
  72. package/src/forms-old/input/InputSelect.svelte +0 -23
  73. package/src/forms-old/input/InputSwitch.svelte +0 -19
  74. package/src/forms-old/input/types.js +0 -29
  75. package/src/forms-old/lib/form.js +0 -72
  76. package/src/forms-old/lib/index.js +0 -12
  77. package/src/forms-old/mocks/CustomField.svelte +0 -7
  78. package/src/forms-old/mocks/CustomWrapper.svelte +0 -8
  79. package/src/forms-old/mocks/Register.svelte +0 -25
  80. package/src/inp/Input.svelte +0 -17
  81. package/src/inp/InputField.svelte +0 -69
  82. package/src/inp/InputSelect.svelte +0 -23
  83. package/src/inp/InputSwitch.svelte +0 -19
  84. package/src/lib/deprecated/Form.svelte +0 -17
  85. package/src/lib/deprecated/FormRenderer.svelte +0 -121
  86. package/src/lib/deprecated/nested.js +0 -192
  87. package/src/lib/deprecated/nested.spec.js +0 -512
  88. package/src/lib/deprecated/validator.js +0 -137
  89. package/src/lib/deprecated/validator.spec.js +0 -348
@@ -1,23 +1,46 @@
1
1
  <script>
2
2
  /**
3
3
  * FormRenderer component with snippet-based rendering
4
- * Handles defaultInput and custom child snippet selection based on override flag
4
+ * Handles defaultInput, info, separator, display components, and custom child snippet selection.
5
+ * Supports form submission with validate-before-submit, loading state, and optional action buttons.
5
6
  */
6
7
 
8
+ import { onMount, untrack } from 'svelte'
7
9
  import InputField from './InputField.svelte'
10
+ import InfoField from './InfoField.svelte'
8
11
  import { FormBuilder } from './lib/builder.svelte.js'
12
+ import { defaultRenderers } from './lib/renderers.js'
13
+ import DisplayTable from './display/DisplayTable.svelte'
14
+ import DisplayCardGrid from './display/DisplayCardGrid.svelte'
15
+ import DisplaySection from './display/DisplaySection.svelte'
16
+ import DisplayList from './display/DisplayList.svelte'
9
17
 
10
18
  let {
11
- // FormBuilder binding
19
+ // Optional external builder instance
20
+ builder = undefined,
12
21
 
13
22
  // Direct props (alternative to builder)
14
23
  data = $bindable(),
15
24
  schema = null,
16
25
  layout = null,
17
26
 
27
+ // Lookup configurations
28
+ lookups = {},
29
+
30
+ // Validation mode: 'blur' | 'change' | 'manual'
31
+ validateOn = 'blur',
32
+
33
+ // Custom type renderers (merged with defaults)
34
+ renderers = {},
35
+
18
36
  // Event handlers
19
37
  onupdate = undefined,
20
38
  onvalidate = undefined,
39
+ onselect = undefined,
40
+ onsubmit = undefined,
41
+
42
+ // Custom actions snippet: receives { submitting, isValid, isDirty, submit, reset }
43
+ actions = undefined,
21
44
 
22
45
  // Styling
23
46
  className = '',
@@ -29,90 +52,208 @@
29
52
  ...props
30
53
  } = $props()
31
54
 
32
- // Use provided builder or create one from data/schema/layout
33
- let formBuilder = $derived(new FormBuilder(data, schema, layout))
34
- // Get elements from the builder
35
- let elements = $derived(() => formBuilder.elements)
55
+ // Merged renderer registry
56
+ const allRenderers = $derived({ ...defaultRenderers, ...renderers })
57
+
58
+ // Stable FormBuilder instance created once, updated via $effect
59
+ let formBuilder = untrack(() => builder ?? new FormBuilder(data, schema, layout, lookups))
60
+
61
+ // Initialize lookups after mount (async fire-and-forget; $state updates trigger re-derive)
62
+ onMount(() => {
63
+ if (Object.keys(lookups).length > 0) {
64
+ formBuilder.initializeLookups()
65
+ }
66
+ })
67
+
68
+ // Sync prop changes to builder
69
+ $effect(() => {
70
+ if (formBuilder.data !== data) {
71
+ formBuilder.data = data
72
+ }
73
+ })
74
+
75
+ $effect(() => {
76
+ if (schema !== null && schema !== undefined) formBuilder.schema = schema
77
+ })
78
+
79
+ $effect(() => {
80
+ if (layout !== null && layout !== undefined) formBuilder.layout = layout
81
+ })
36
82
 
37
83
  // Handle field value changes
38
84
  function handleFieldChange(element, newValue) {
39
85
  const fieldPath = element.scope.replace(/^#\//, '')
40
86
 
41
- if (formBuilder) {
42
- // Update through FormBuilder
43
- formBuilder.updateField(fieldPath, newValue)
44
- } else {
45
- // Update data directly
46
- updateNestedValue(data, fieldPath, newValue)
47
- }
87
+ // Update builder (source of truth)
88
+ formBuilder.updateField(fieldPath, newValue)
89
+
90
+ // Sync builder data back to bindable prop
91
+ data = formBuilder.data
48
92
 
49
93
  // Call onupdate callback if provided
50
94
  if (onupdate) {
51
- const currentData = formBuilder ? formBuilder.data : data
52
- onupdate(currentData)
95
+ onupdate(data)
96
+ }
97
+
98
+ // Built-in validation on change
99
+ if (validateOn === 'change') {
100
+ formBuilder.validateField(fieldPath)
53
101
  }
54
102
 
55
- // Trigger validation if handler provided
103
+ // External validation callback (backward compat)
56
104
  if (onvalidate) {
57
- onvalidate(fieldPath, newValue)
105
+ const result = onvalidate(fieldPath, newValue, 'change')
106
+ if (result && typeof result === 'object' && result.state) {
107
+ formBuilder.setFieldValidation(fieldPath, result)
108
+ }
58
109
  }
59
110
  }
60
111
 
61
- // Helper function to update nested object values
62
- function updateNestedValue(obj, path, value) {
63
- const keys = path.split('/')
64
- let current = obj
112
+ // Handle blur events for validation
113
+ function handleFieldBlur(element) {
114
+ const fieldPath = element.scope.replace(/^#\//, '')
115
+ const currentValue = formBuilder.getValue(fieldPath)
65
116
 
66
- for (let i = 0; i < keys.length - 1; i++) {
67
- if (!(keys[i] in current)) {
68
- current[keys[i]] = {}
69
- }
70
- current = current[keys[i]]
117
+ // Built-in validation on blur (default mode)
118
+ if (validateOn === 'blur') {
119
+ formBuilder.validateField(fieldPath)
71
120
  }
72
121
 
73
- current[keys[keys.length - 1]] = value
122
+ // External validation callback (backward compat)
123
+ if (onvalidate) {
124
+ const result = onvalidate(fieldPath, currentValue, 'blur')
125
+ if (result && typeof result === 'object' && result.state) {
126
+ formBuilder.setFieldValidation(fieldPath, result)
127
+ }
128
+ }
74
129
  }
75
130
 
76
- // Handle focus events for validation
77
- function handleFieldFocus(element) {
78
- // Could trigger validation on focus if needed
79
- }
131
+ // Submission state
132
+ let submitting = $state(false)
133
+ let formRoot = $state(null)
80
134
 
81
- // Handle blur events for validation
82
- function handleFieldBlur(element) {
135
+ // Handle form submission: validate → focus first error → call onsubmit → snapshot
136
+ async function handleSubmit(e) {
137
+ e.preventDefault()
138
+ if (submitting || !onsubmit) return
139
+
140
+ // Validate all fields
141
+ formBuilder.validate()
142
+
143
+ // External form-level validation
83
144
  if (onvalidate) {
84
- const fieldPath = element.scope.replace(/^#\//, '')
85
- const currentValue = element.value
86
- onvalidate(fieldPath, currentValue, 'blur')
145
+ const result = onvalidate('*', data, 'submit')
146
+ if (result && typeof result === 'object' && result.state) {
147
+ formBuilder.setFieldValidation('*', result)
148
+ }
87
149
  }
150
+
151
+ // If invalid, focus first error field
152
+ if (!formBuilder.isValid) {
153
+ const firstError = formBuilder.errors[0]
154
+ if (firstError && formRoot) {
155
+ const field = formRoot.querySelector(
156
+ `[data-scope="#/${firstError.path}"] input, [data-scope="#/${firstError.path}"] select, [data-scope="#/${firstError.path}"] textarea`
157
+ )
158
+ field?.focus?.()
159
+ }
160
+ return
161
+ }
162
+
163
+ // Submit
164
+ submitting = true
165
+ try {
166
+ await onsubmit(data, { isValid: true, errors: [] })
167
+ formBuilder.snapshot()
168
+ } catch {
169
+ // Consumer handles errors in their onsubmit callback
170
+ } finally {
171
+ submitting = false
172
+ }
173
+ }
174
+
175
+ // Handle form reset
176
+ function handleReset() {
177
+ formBuilder.reset()
178
+ data = formBuilder.data
88
179
  }
89
180
  </script>
90
181
 
91
- <!-- Form container -->
92
- <div data-form-root class={className} {...props}>
93
- <!-- <div data-form-field data-scope={formBuilder.elements[0].scope}>
94
- {@render defaultInput(formBuilder.elements[0])}
95
- </div> -->
96
- {#each formBuilder.elements as element, index (index)}
97
- <div data-form-field data-scope={element.scope}>
98
- {#if element.override && child}
99
- {@render child(element)}
100
- {:else}
101
- {@render defaultInput(element)}
182
+ <!-- Form container: <form> when onsubmit provided, <div> otherwise -->
183
+ {#if onsubmit}
184
+ <form
185
+ bind:this={formRoot}
186
+ data-form-root
187
+ data-form-submitting={submitting || undefined}
188
+ class={className}
189
+ onsubmit={handleSubmit}
190
+ {...props}
191
+ >
192
+ {#each formBuilder.elements as element, index (index)}
193
+ {@render renderElement(element)}
194
+ {/each}
195
+ {#if actions}
196
+ {@render actions({ submitting, isValid: formBuilder.isValid, isDirty: formBuilder.isDirty, submit: handleSubmit, reset: handleReset })}
197
+ {:else}
198
+ <div data-form-actions>
199
+ <button type="button" data-form-reset disabled={!formBuilder.isDirty || submitting} onclick={handleReset}>Reset</button>
200
+ <button type="submit" data-form-submit disabled={!formBuilder.isDirty || submitting}>Submit</button>
201
+ </div>
202
+ {/if}
203
+ </form>
204
+ {:else}
205
+ <div bind:this={formRoot} data-form-root class={className} {...props}>
206
+ {#each formBuilder.elements as element, index (index)}
207
+ {@render renderElement(element)}
208
+ {/each}
209
+ </div>
210
+ {/if}
211
+
212
+ <!-- Render a single element by type -->
213
+ {#snippet renderElement(element)}
214
+ {#if element.type === 'separator'}
215
+ <div data-form-separator></div>
216
+ {:else if element.type === 'group'}
217
+ <fieldset data-form-group data-scope={element.scope}>
218
+ {#if element.props?.label}
219
+ <legend data-form-group-label>{element.props.label}</legend>
102
220
  {/if}
221
+ {#each element.props?.elements ?? [] as child_element, i (child_element.scope ?? i)}
222
+ {@render renderElement(child_element)}
223
+ {/each}
224
+ </fieldset>
225
+ {:else if element.type === 'display-table'}
226
+ <DisplayTable data={element.value} {...element.props} {onselect} />
227
+ {:else if element.type === 'display-cards'}
228
+ <DisplayCardGrid data={element.value} {...element.props} {onselect} />
229
+ {:else if element.type === 'display-section'}
230
+ <DisplaySection data={element.value} {...element.props} />
231
+ {:else if element.type === 'display-list'}
232
+ <DisplayList data={element.value} {...element.props} />
233
+ {:else if element.type === 'info'}
234
+ <div data-form-field data-scope={element.scope}>
235
+ <InfoField
236
+ name={element.scope}
237
+ value={element.value}
238
+ label={element.props?.label}
239
+ description={element.props?.description}
240
+ />
241
+ </div>
242
+ {:else if element.override && child}
243
+ <div data-form-field data-scope={element.scope}>
244
+ {@render child(element)}
245
+ </div>
246
+ {:else}
247
+ <div data-form-field data-scope={element.scope}>
248
+ <InputField
249
+ name={element.scope}
250
+ type={element.type}
251
+ value={element.value}
252
+ {...element.props}
253
+ renderers={allRenderers}
254
+ onchange={(newValue) => handleFieldChange(element, newValue)}
255
+ onblur={() => handleFieldBlur(element)}
256
+ />
103
257
  </div>
104
- {/each}
105
- </div>
106
-
107
- <!-- Default input snippet -->
108
- {#snippet defaultInput(element)}
109
- <InputField
110
- name={element.scope}
111
- type={element.type}
112
- value={element.value}
113
- {...element.props}
114
- onchange={(newValue) => handleFieldChange(element, newValue)}
115
- onfocus={() => handleFieldFocus(element)}
116
- onblur={() => handleFieldBlur(element)}
117
- />
258
+ {/if}
118
259
  {/snippet}
@@ -0,0 +1,26 @@
1
+ <script>
2
+ let {
3
+ class: className,
4
+ name,
5
+ value,
6
+ label,
7
+ description
8
+ } = $props()
9
+ </script>
10
+
11
+ <div
12
+ data-field-root
13
+ class={className}
14
+ data-field-type="info"
15
+ data-field-empty={value === null || value === undefined}
16
+ >
17
+ <div data-field aria-label={description ?? label ?? name}>
18
+ {#if label}
19
+ <label for={name}>{label}</label>
20
+ {/if}
21
+ <span data-field-info>{value === undefined ? '—' : String(value)}</span>
22
+ </div>
23
+ {#if description}
24
+ <div data-description>{description}</div>
25
+ {/if}
26
+ </div>
package/src/Input.svelte CHANGED
@@ -1,22 +1,5 @@
1
1
  <script>
2
- import { Icon } from '@rokkit/ui'
3
- import InputCheckbox from './input/InputCheckbox.svelte'
4
- import InputColor from './input/InputColor.svelte'
5
- import InputDate from './input/InputDate.svelte'
6
- import InputDateTime from './input/InputDateTime.svelte'
7
- import InputEmail from './input/InputEmail.svelte'
8
- import InputFile from './input/InputFile.svelte'
9
- import InputMonth from './input/InputMonth.svelte'
10
- import InputNumber from './input/InputNumber.svelte'
11
- import InputPassword from './input/InputPassword.svelte'
12
- import InputRange from './input/InputRange.svelte'
13
- import InputTel from './input/InputTel.svelte'
14
- import InputText from './input/InputText.svelte'
15
- import InputTextArea from './input/InputTextArea.svelte'
16
- import InputTime from './input/InputTime.svelte'
17
- import InputUrl from './input/InputUrl.svelte'
18
- import InputWeek from './input/InputWeek.svelte'
19
- import InputSelect from './input/InputSelect.svelte'
2
+ import { defaultRenderers, resolveRenderer } from './lib/renderers.js'
20
3
 
21
4
  let {
22
5
  type = 'text',
@@ -26,50 +9,23 @@
26
9
  onfocus = null,
27
10
  onblur = null,
28
11
  icon = null,
12
+ renderers = {},
29
13
  ...restProps
30
14
  } = $props()
15
+
16
+ const allRenderers = $derived({ ...defaultRenderers, ...renderers })
17
+ const extraProps = $derived(type === 'integer' ? { step: '1' } : {})
31
18
  </script>
32
19
 
33
- <div data-input-root>
34
- {#if icon}
35
- <Icon name={icon} />
36
- {/if}
37
- {#if type === 'number'}
38
- <InputNumber bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
39
- {:else if type === 'integer'}
40
- <InputNumber bind:value {onchange} {oninput} {onfocus} {onblur} step="1" {...restProps} />
41
- {:else if type === 'password'}
42
- <InputPassword bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
43
- {:else if type === 'checkbox'}
44
- <InputCheckbox bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
45
- {:else if type === 'color'}
46
- <InputColor bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
47
- {:else if type === 'date'}
48
- <InputDate bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
49
- {:else if type === 'email'}
50
- <InputEmail bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
51
- {:else if type === 'tel'}
52
- <InputTel bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
53
- {:else if type === 'time'}
54
- <InputTime bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
55
- {:else if ['datetime', 'datetime-local'].includes(type)}
56
- <InputDateTime bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
57
- {:else if type === 'month'}
58
- <InputMonth bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
59
- {:else if type === 'range'}
60
- <InputRange bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
61
- {:else if type === 'select'}
62
- <InputSelect bind:value {onchange} {onfocus} {onblur} {...restProps} />
63
- {:else if type === 'textarea'}
64
- <InputTextArea bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
65
- {:else if type === 'url'}
66
- <InputUrl bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
67
- {:else if type === 'week'}
68
- <InputWeek bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
69
- <!-- {:else if type === 'switch'}
70
- <InputWeek bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} /> -->
71
- {:else}
72
- <!-- Default to text input -->
73
- <InputText bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
74
- {/if}
75
- </div>
20
+ {#if type === 'checkbox'}
21
+ {@const Component = resolveRenderer({ type: 'checkbox' }, allRenderers)}
22
+ <Component bind:value {onchange} {oninput} {onfocus} {onblur} {...restProps} />
23
+ {:else}
24
+ {@const Component = resolveRenderer({ type, props: restProps }, allRenderers)}
25
+ <div data-input-root>
26
+ {#if icon}
27
+ <span class={icon} aria-hidden="true"></span>
28
+ {/if}
29
+ <Component bind:value {onchange} {oninput} {onfocus} {onblur} {...extraProps} {...restProps} />
30
+ </div>
31
+ {/if}
@@ -1,6 +1,4 @@
1
1
  <script>
2
- import { pick, omit, isNil } from 'ramda'
3
- import { Icon } from '@rokkit/ui'
4
2
  import Input from './Input.svelte'
5
3
 
6
4
  let {
@@ -11,45 +9,51 @@
11
9
  required,
12
10
  status,
13
11
  disabled,
12
+ dirty,
14
13
  message,
15
14
  nolabel,
16
15
  icon,
17
16
  label,
18
17
  description,
19
18
  onchange,
19
+ id,
20
+ renderers,
20
21
  ...restProps
21
22
  } = $props()
22
23
 
23
- let rootProps = pick(['id'], restProps)
24
- let properties = {
24
+ let rootProps = $derived(id !== null && id !== undefined ? { id } : {})
25
+ let properties = $derived({
25
26
  required,
26
27
  readOnly: disabled,
27
- ...omit(['id'], restProps),
28
+ ...restProps,
28
29
  name
29
- }
30
+ })
30
31
  </script>
31
32
 
32
33
  <div
33
34
  data-field-root
34
35
  {...rootProps}
35
36
  class={className}
36
- data-field-state={status}
37
+ data-field-state={status ?? message?.state}
37
38
  data-field-type={type}
38
39
  data-field-required={required}
39
40
  data-field-disabled={disabled}
40
- data-field-empty={isNil(value)}
41
- data-has-icon={!isNil(icon)}
41
+ data-field-dirty={dirty || undefined}
42
+ data-field-empty={value === null || value === undefined}
43
+ data-has-icon={icon !== null && icon !== undefined}
42
44
  >
43
45
  <div data-field aria-label={description ?? label ?? name}>
44
46
  {#if label && !nolabel}
45
47
  <label for={name}>{label}</label>
46
48
  {/if}
47
- <Input id={name} bind:value {type} {...properties} {onchange} {icon} />
49
+ <Input id={name} bind:value {type} {...properties} {onchange} {icon} {renderers} />
48
50
  </div>
49
51
  {#if description}
50
52
  <div data-description>{description}</div>
51
53
  {/if}
52
54
  {#if message}
53
- <div data-message>{message}</div>
55
+ <div data-message data-message-state={message?.state ?? 'info'}>
56
+ {message?.text ?? message}
57
+ </div>
54
58
  {/if}
55
59
  </div>
@@ -0,0 +1,52 @@
1
+ <script>
2
+ /**
3
+ * ValidationReport — grouped summary of validation messages
4
+ * Groups items by severity (error, warning, info, success) with count headers.
5
+ * Supports click-to-focus via onclick callback.
6
+ */
7
+
8
+ const SEVERITY_ORDER = ['error', 'warning', 'info', 'success']
9
+ const SEVERITY_LABELS = { error: 'error', warning: 'warning', info: 'info', success: 'success' }
10
+
11
+ let {
12
+ items = [],
13
+ onclick = undefined,
14
+ class: className = ''
15
+ } = $props()
16
+
17
+ const grouped = $derived(
18
+ SEVERITY_ORDER.map((state) => ({
19
+ state,
20
+ items: items.filter((item) => item.state === state)
21
+ })).filter((group) => group.items.length > 0)
22
+ )
23
+ </script>
24
+
25
+ {#if items.length > 0}
26
+ <div data-validation-report class={className} role="status">
27
+ {#each grouped as group (group.state)}
28
+ <div data-validation-group data-severity={group.state}>
29
+ <div data-validation-group-header>
30
+ <span data-validation-count>{group.items.length}</span>
31
+ <span>{group.items.length === 1 ? SEVERITY_LABELS[group.state] : `${SEVERITY_LABELS[group.state]}s`}</span>
32
+ </div>
33
+ {#each group.items as item (item.path)}
34
+ {#if onclick}
35
+ <button
36
+ data-validation-item
37
+ data-status={item.state}
38
+ onclick={() => onclick(item.path)}
39
+ type="button"
40
+ >
41
+ {item.text}
42
+ </button>
43
+ {:else}
44
+ <div data-validation-item data-status={item.state}>
45
+ {item.text}
46
+ </div>
47
+ {/if}
48
+ {/each}
49
+ </div>
50
+ {/each}
51
+ </div>
52
+ {/if}
@@ -0,0 +1,68 @@
1
+ <script>
2
+ /**
3
+ * Renders an array of objects as a responsive card grid.
4
+ * Each card displays fields with formatted values.
5
+ */
6
+ import DisplayValue from './DisplayValue.svelte'
7
+
8
+ let {
9
+ data = [],
10
+ fields = [],
11
+ select,
12
+ title,
13
+ onselect,
14
+ class: className = ''
15
+ } = $props()
16
+
17
+ let selectedIndex = $state(-1)
18
+ let selectedIndices = $state(new Set())
19
+
20
+ function handleCardClick(item, index) {
21
+ if (!select) return
22
+
23
+ if (select === 'one') {
24
+ selectedIndex = index
25
+ onselect?.(item, item)
26
+ } else if (select === 'many') {
27
+ const next = new Set(selectedIndices)
28
+ if (next.has(index)) {
29
+ next.delete(index)
30
+ } else {
31
+ next.add(index)
32
+ }
33
+ selectedIndices = next
34
+ const selected = data.filter((_, i) => next.has(i))
35
+ onselect?.(selected, item)
36
+ }
37
+ }
38
+
39
+ function isSelected(index) {
40
+ if (select === 'one') return selectedIndex === index
41
+ if (select === 'many') return selectedIndices.has(index)
42
+ return false
43
+ }
44
+ </script>
45
+
46
+ <div data-display-cards data-selectable={select || undefined} class={className}>
47
+ {#if title}
48
+ <div data-display-title>{title}</div>
49
+ {/if}
50
+ <div data-display-grid>
51
+ {#each data as item, index (index)}
52
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
53
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
54
+ <div
55
+ data-display-card
56
+ data-selected={isSelected(index) || undefined}
57
+ onclick={select ? () => handleCardClick(item, index) : undefined}
58
+ >
59
+ {#each fields as field (field.key)}
60
+ <div data-display-field>
61
+ <span data-display-label>{field.label ?? field.key}</span>
62
+ <DisplayValue value={item[field.key]} format={field.format} />
63
+ </div>
64
+ {/each}
65
+ </div>
66
+ {/each}
67
+ </div>
68
+ </div>
@@ -0,0 +1,31 @@
1
+ <script>
2
+ /**
3
+ * Renders an array of objects as a styled list.
4
+ */
5
+ import DisplayValue from './DisplayValue.svelte'
6
+
7
+ let {
8
+ data = [],
9
+ fields = [],
10
+ title,
11
+ class: className = ''
12
+ } = $props()
13
+ </script>
14
+
15
+ <div data-display-list class={className}>
16
+ {#if title}
17
+ <div data-display-title>{title}</div>
18
+ {/if}
19
+ <ul>
20
+ {#each data as item, index (index)}
21
+ <li data-display-list-item>
22
+ {#each fields as field (field.key)}
23
+ <span data-display-field>
24
+ <span data-display-label>{field.label ?? field.key}</span>
25
+ <DisplayValue value={item[field.key]} format={field.format} />
26
+ </span>
27
+ {/each}
28
+ </li>
29
+ {/each}
30
+ </ul>
31
+ </div>
@@ -0,0 +1,20 @@
1
+ <script>
2
+ /**
3
+ * Renders an object as key-value pairs (detail/summary view).
4
+ */
5
+ import DisplayValue from './DisplayValue.svelte'
6
+
7
+ let { data = {}, fields = [], title } = $props()
8
+ </script>
9
+
10
+ <div data-display-section>
11
+ {#if title}
12
+ <div data-display-title>{title}</div>
13
+ {/if}
14
+ {#each fields as field (field.key)}
15
+ <div data-display-field>
16
+ <span data-display-label>{field.label ?? field.key}</span>
17
+ <DisplayValue value={data[field.key]} format={field.format} />
18
+ </div>
19
+ {/each}
20
+ </div>