@rokkit/forms 1.0.0-next.132 → 1.0.0-next.134
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/LICENSE +21 -0
- package/README.md +129 -185
- package/dist/src/lib/builder.svelte.d.ts +7 -0
- package/dist/src/lib/conditions.d.ts +13 -0
- package/dist/src/lib/conditions.spec.d.ts +1 -0
- package/dist/src/lib/index.d.ts +1 -0
- package/package.json +6 -3
- package/src/FormRenderer.svelte +17 -4
- package/src/InfoField.svelte +1 -7
- package/src/ValidationReport.svelte +6 -6
- package/src/display/DisplayCardGrid.svelte +1 -8
- package/src/display/DisplayList.svelte +1 -6
- package/src/display/DisplayTable.svelte +1 -8
- package/src/input/ArrayEditor.svelte +9 -4
- package/src/input/InputCheckbox.svelte +9 -1
- package/src/input/InputSelect.svelte +4 -2
- package/src/lib/builder.svelte.js +56 -9
- package/src/lib/conditions.js +15 -0
- package/src/lib/conditions.spec.js +58 -0
- package/src/lib/fields.js +0 -2
- package/src/lib/index.js +1 -0
- package/src/lib/layout.js +1 -2
- package/src/lib/schema.js +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jerry Thomas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,251 +1,195 @@
|
|
|
1
1
|
# @rokkit/forms
|
|
2
2
|
|
|
3
|
-
Schema-driven form
|
|
3
|
+
Schema-driven form builder and renderer for Svelte 5 applications.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
+
npm install @rokkit/forms
|
|
9
|
+
# or
|
|
8
10
|
bun add @rokkit/forms
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
##
|
|
13
|
+
## Overview
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
`@rokkit/forms` generates dynamic forms from a data object, a JSON Schema definition, and a layout configuration. Both schema and layout can be auto-derived from data, so you can get a working form with zero configuration. The `FormBuilder` class produces a reactive `elements[]` array; the `FormRenderer` component renders it.
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
## Usage
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
import { FormBuilder } from '@rokkit/forms'
|
|
19
|
+
### Minimal — auto-derive everything
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
layout // optional — auto-derived from data
|
|
24
|
-
)
|
|
21
|
+
```svelte
|
|
22
|
+
<script>
|
|
23
|
+
import { FormRenderer } from '@rokkit/forms'
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
let data = $state({ name: '', age: 0, active: true })
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<FormRenderer bind:data />
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
Schema and layout are inferred from the data shape automatically.
|
|
30
32
|
|
|
31
|
-
###
|
|
33
|
+
### With explicit schema and layout
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
```svelte
|
|
36
|
+
<script>
|
|
37
|
+
import { FormRenderer } from '@rokkit/forms'
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
let data = $state({ name: '', role: 'viewer', count: 5, active: false })
|
|
40
|
+
|
|
41
|
+
const schema = {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
name: { type: 'string' },
|
|
45
|
+
role: { type: 'string', enum: ['viewer', 'editor', 'admin'] },
|
|
46
|
+
count: { type: 'integer', min: 0, max: 100 },
|
|
47
|
+
active: { type: 'boolean' }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const layout = {
|
|
52
|
+
type: 'vertical',
|
|
53
|
+
elements: [
|
|
54
|
+
{ scope: '#/name', label: 'Full Name', props: { placeholder: 'Enter name' } },
|
|
55
|
+
{ scope: '#/role', label: 'Role' },
|
|
56
|
+
{ scope: '#/count', label: 'Count' },
|
|
57
|
+
{ type: 'separator' },
|
|
58
|
+
{ scope: '#/active', label: 'Active' }
|
|
59
|
+
]
|
|
43
60
|
}
|
|
44
|
-
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<FormRenderer bind:data {schema} {layout} onupdate={(val) => console.log(val)} />
|
|
45
64
|
```
|
|
46
65
|
|
|
47
|
-
###
|
|
66
|
+
### FormBuilder (imperative)
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
Use `FormBuilder` directly when you need to drive forms outside of `FormRenderer`:
|
|
50
69
|
|
|
51
70
|
```js
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
]
|
|
62
|
-
}
|
|
71
|
+
import { FormBuilder } from '@rokkit/forms'
|
|
72
|
+
|
|
73
|
+
const form = new FormBuilder(data, schema, layout)
|
|
74
|
+
|
|
75
|
+
// form.elements → [{ scope, type, value, props }, ...]
|
|
76
|
+
form.updateField('#/name', 'Alice')
|
|
77
|
+
form.validateField('#/name')
|
|
78
|
+
form.validateAll()
|
|
79
|
+
form.isDirty('#/name') // true if value differs from initial
|
|
63
80
|
```
|
|
64
81
|
|
|
65
|
-
###
|
|
82
|
+
### Validation
|
|
66
83
|
|
|
67
|
-
|
|
84
|
+
```js
|
|
85
|
+
import { validateField, validateAll, patterns } from '@rokkit/forms'
|
|
68
86
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
| `string` | default | `text` |
|
|
73
|
-
| `boolean` | — | `checkbox` |
|
|
74
|
-
| `number`/`integer` | has `min` and `max` | `range` |
|
|
75
|
-
| `number`/`integer` | default | `number` |
|
|
76
|
-
| any | `readonly: true` | `info` |
|
|
77
|
-
| — | no `scope` | `separator` |
|
|
87
|
+
const result = validateField(value, { required: true, pattern: patterns.email })
|
|
88
|
+
// result → { state: 'error', text: 'Invalid email address' } | null
|
|
89
|
+
```
|
|
78
90
|
|
|
79
|
-
###
|
|
91
|
+
### Dependent lookups with createLookup
|
|
80
92
|
|
|
81
|
-
|
|
93
|
+
`createLookup` creates a reactive dropdown data source that can depend on other field values:
|
|
82
94
|
|
|
83
95
|
```js
|
|
84
|
-
|
|
85
|
-
{ scope: '#/size', props: { options: ['sm', 'md', 'lg'] } }
|
|
96
|
+
import { createLookup } from '@rokkit/forms'
|
|
86
97
|
|
|
87
|
-
//
|
|
88
|
-
{
|
|
89
|
-
options: [{ name: 'Red', hex: '#f00' }, { name: 'Blue', hex: '#00f' }],
|
|
90
|
-
fields: { text: 'name', value: 'hex' }
|
|
91
|
-
}}
|
|
98
|
+
// Fetch from a URL template — {region} is replaced by the current field value
|
|
99
|
+
const countriesLookup = createLookup({ url: '/api/countries?region={region}' })
|
|
92
100
|
|
|
93
|
-
//
|
|
94
|
-
|
|
101
|
+
// Async fetch function — receives current dependency values
|
|
102
|
+
const citiesLookup = createLookup({
|
|
103
|
+
fetch: async (deps) => {
|
|
104
|
+
if (!deps.country) return { disabled: true, options: [] }
|
|
105
|
+
return { options: await getCities(deps.country) }
|
|
106
|
+
}
|
|
107
|
+
})
|
|
95
108
|
|
|
96
|
-
//
|
|
97
|
-
|
|
109
|
+
// Client-side filter — no network request
|
|
110
|
+
const filteredLookup = createLookup({
|
|
111
|
+
source: allOptions,
|
|
112
|
+
filter: (item, deps) => item.region === deps.region
|
|
113
|
+
})
|
|
98
114
|
```
|
|
99
115
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
### FormRenderer
|
|
116
|
+
### Custom field rendering
|
|
103
117
|
|
|
104
|
-
|
|
118
|
+
Override individual fields with your own components using the `child` snippet and `override: true` in the layout:
|
|
105
119
|
|
|
106
120
|
```svelte
|
|
107
121
|
<script>
|
|
108
122
|
import { FormRenderer } from '@rokkit/forms'
|
|
109
123
|
|
|
110
|
-
|
|
124
|
+
const layout = {
|
|
125
|
+
type: 'vertical',
|
|
126
|
+
elements: [
|
|
127
|
+
{ scope: '#/tags', label: 'Tags', override: true },
|
|
128
|
+
{ scope: '#/name', label: 'Name' }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
111
131
|
</script>
|
|
112
132
|
|
|
113
|
-
<FormRenderer bind:data {schema} {layout} />
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
**Props:**
|
|
117
|
-
- `data` (bindable) — form data object
|
|
118
|
-
- `schema` — JSON schema (optional, auto-derived)
|
|
119
|
-
- `layout` — layout definition (optional, auto-derived)
|
|
120
|
-
- `onupdate` — callback when data changes
|
|
121
|
-
- `onvalidate` — callback for field validation
|
|
122
|
-
- `child` — snippet for custom field rendering (receives `element`)
|
|
123
|
-
|
|
124
|
-
**Custom field rendering:**
|
|
125
|
-
|
|
126
|
-
```svelte
|
|
127
133
|
<FormRenderer bind:data {schema} {layout}>
|
|
128
134
|
{#snippet child(element)}
|
|
129
|
-
|
|
135
|
+
<!-- rendered for elements with override: true -->
|
|
136
|
+
<TagInput value={element.value} onchange={(v) => form.updateField(element.scope, v)} />
|
|
130
137
|
{/snippet}
|
|
131
138
|
</FormRenderer>
|
|
132
139
|
```
|
|
133
140
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
### InputField
|
|
141
|
+
## Type resolution
|
|
137
142
|
|
|
138
|
-
|
|
143
|
+
`FormBuilder` maps schema types to input types automatically:
|
|
139
144
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
| Schema type | Condition | Input type |
|
|
146
|
+
| -------------------- | --------------------------------- | ----------- |
|
|
147
|
+
| `string` | has `enum` or `options` in layout | `select` |
|
|
148
|
+
| `string` | default | `text` |
|
|
149
|
+
| `boolean` | — | `checkbox` |
|
|
150
|
+
| `number` / `integer` | has both `min` and `max` | `range` |
|
|
151
|
+
| `number` / `integer` | default | `number` |
|
|
152
|
+
| any | `readonly: true` in layout | `info` |
|
|
153
|
+
| — | no `scope` | `separator` |
|
|
143
154
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
Type dispatcher — renders the appropriate input component based on `type`:
|
|
147
|
-
|
|
148
|
-
- `text`, `email`, `password`, `url`, `tel` — text inputs
|
|
149
|
-
- `number` — numeric input
|
|
150
|
-
- `range` — slider
|
|
151
|
-
- `checkbox` — checkbox
|
|
152
|
-
- `select` — dropdown (uses `@rokkit/ui` Select)
|
|
153
|
-
- `date`, `time`, `datetime-local`, `month`, `week` — date/time inputs
|
|
154
|
-
- `color` — color picker
|
|
155
|
-
- `file` — file upload
|
|
156
|
-
- `textarea` — multiline text
|
|
157
|
-
- `radio` — radio group
|
|
158
|
-
|
|
159
|
-
### InfoField
|
|
160
|
-
|
|
161
|
-
Read-only display of a label and value:
|
|
155
|
+
## Components
|
|
162
156
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
157
|
+
| Component | Description |
|
|
158
|
+
| ------------------ | --------------------------------------------------------------------- |
|
|
159
|
+
| `FormRenderer` | Renders a complete form from data + schema + layout |
|
|
160
|
+
| `Input` | Type-dispatching input — renders the right control for a given `type` |
|
|
161
|
+
| `InputField` | Input wrapped with label, description, and validation message |
|
|
162
|
+
| `InfoField` | Read-only label + value display |
|
|
163
|
+
| `ValidationReport` | Displays a list of validation messages |
|
|
166
164
|
|
|
167
|
-
##
|
|
165
|
+
## Utilities
|
|
168
166
|
|
|
169
167
|
```js
|
|
170
|
-
import {
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
import {
|
|
169
|
+
deriveSchemaFromValue, // infer JSON schema from a data object
|
|
170
|
+
deriveLayoutFromValue, // generate a default vertical layout from data
|
|
171
|
+
getSchemaWithLayout, // merge schema attributes with layout elements
|
|
172
|
+
findAttributeByPath, // look up a schema attribute by JSON Pointer path
|
|
173
|
+
validateField, // validate a single value against schema constraints
|
|
174
|
+
validateAll, // validate all fields in a FormBuilder
|
|
175
|
+
patterns // regex patterns: email, url, phone, etc.
|
|
176
|
+
} from '@rokkit/forms'
|
|
173
177
|
```
|
|
174
178
|
|
|
175
|
-
- `deriveSchemaFromValue(data)` — infer schema from a data object
|
|
176
|
-
- `deriveLayoutFromValue(data)` — generate default vertical layout from data
|
|
177
|
-
- `getSchemaWithLayout(schema, layout)` — merge schema attributes with layout elements
|
|
178
|
-
- `findAttributeByPath(scope, schema)` — find schema attribute by JSON Pointer path
|
|
179
|
-
|
|
180
179
|
## Theming
|
|
181
180
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
| Selector | Purpose |
|
|
185
|
-
|---|---|
|
|
186
|
-
| `[data-form-root]` | Form container |
|
|
187
|
-
| `[data-form-field]` | Field wrapper |
|
|
188
|
-
| `[data-form-separator]` | Separator between fields |
|
|
189
|
-
| `[data-field-root]` | Input field root |
|
|
190
|
-
| `[data-field]` | Field inner wrapper |
|
|
191
|
-
| `[data-field-type="..."]` | Type-specific layout (checkbox, info, select) |
|
|
192
|
-
| `[data-field-info]` | Info/readonly value display |
|
|
193
|
-
| `[data-input-root]` | Input element wrapper |
|
|
194
|
-
| `[data-description]` | Help text |
|
|
195
|
-
| `[data-message]` | Validation message |
|
|
196
|
-
|
|
197
|
-
## Future Enhancements
|
|
198
|
-
|
|
199
|
-
### Toggle/Switch as Checkbox Alternative
|
|
200
|
-
|
|
201
|
-
Use `@rokkit/ui` Toggle or Switch components as alternatives to the native checkbox for boolean fields. The layout could specify the preferred component:
|
|
202
|
-
|
|
203
|
-
```js
|
|
204
|
-
{ scope: '#/active', label: 'Active', props: { component: 'switch' } }
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
### Toggle as Select Alternative
|
|
181
|
+
Components use `data-*` attribute selectors. Apply styles via `@rokkit/themes` or target these hooks directly:
|
|
208
182
|
|
|
209
|
-
|
|
183
|
+
| Selector | Purpose |
|
|
184
|
+
| ------------------------- | -------------------- |
|
|
185
|
+
| `[data-form-root]` | Form container |
|
|
186
|
+
| `[data-form-field]` | Field wrapper |
|
|
187
|
+
| `[data-form-separator]` | Separator element |
|
|
188
|
+
| `[data-field-root]` | Input field root |
|
|
189
|
+
| `[data-field-type="..."]` | Type-specific layout |
|
|
190
|
+
| `[data-description]` | Help text |
|
|
191
|
+
| `[data-message]` | Validation message |
|
|
210
192
|
|
|
211
|
-
|
|
212
|
-
{ scope: '#/size', label: 'Size', props: { component: 'toggle', options: ['sm', 'md', 'lg'] } }
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
**Constraints:**
|
|
216
|
-
- **Text options**: max 3 options (beyond 3, text labels overflow in compact layouts)
|
|
217
|
-
- **Icon-only options**: max 5 options (icons are more compact)
|
|
218
|
-
- When the option count exceeds these limits, fall back to Select automatically
|
|
219
|
-
|
|
220
|
-
### MultiSelect for Array Values
|
|
221
|
-
|
|
222
|
-
When the value is an array and `options` is present in the layout, render a MultiSelect component instead of a single-value Select:
|
|
223
|
-
|
|
224
|
-
```js
|
|
225
|
-
// Schema: tags is an array
|
|
226
|
-
{ tags: { type: 'array', items: { type: 'string' } } }
|
|
227
|
-
|
|
228
|
-
// Layout: options provided → MultiSelect
|
|
229
|
-
{ scope: '#/tags', label: 'Tags', props: { options: ['bug', 'feature', 'docs'] } }
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
### Connected/Dependent Props
|
|
233
|
-
|
|
234
|
-
Support cascading dependencies between fields — changing one field's value updates another field's options. For example, selecting a country updates the available cities:
|
|
235
|
-
|
|
236
|
-
```js
|
|
237
|
-
const lookups = {
|
|
238
|
-
city: {
|
|
239
|
-
dependsOn: 'country',
|
|
240
|
-
fetch: async (country) => getCitiesForCountry(country)
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const builder = new FormBuilder(data, schema, layout, lookups)
|
|
245
|
-
await builder.initializeLookups()
|
|
246
|
-
```
|
|
193
|
+
---
|
|
247
194
|
|
|
248
|
-
|
|
249
|
-
- Auto-wiring lookup state (options, loading) into element props
|
|
250
|
-
- UI indicators for loading state on dependent fields
|
|
251
|
-
- Debouncing rapid changes to parent fields
|
|
195
|
+
Part of [Rokkit](https://github.com/jerrythomas/rokkit) — a Svelte 5 component library and design system.
|
|
@@ -157,6 +157,13 @@ export class FormBuilder {
|
|
|
157
157
|
* @returns {Object} Validation results keyed by field path
|
|
158
158
|
*/
|
|
159
159
|
validate(): Object;
|
|
160
|
+
/**
|
|
161
|
+
* Get form data with hidden field values stripped out
|
|
162
|
+
* Hidden fields are those absent from this.elements (the derived list)
|
|
163
|
+
* Does not mutate this.#data
|
|
164
|
+
* @returns {Object} Filtered data containing only visible field keys
|
|
165
|
+
*/
|
|
166
|
+
getVisibleData(): Object;
|
|
160
167
|
/**
|
|
161
168
|
* Whether all fields pass validation (no error-state messages)
|
|
162
169
|
* @returns {boolean}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate a showWhen condition against current form data.
|
|
3
|
+
* Returns true if the field should be shown (condition met or absent).
|
|
4
|
+
*
|
|
5
|
+
* @param {{ field: string, equals?: unknown, notEquals?: unknown } | null | undefined} condition
|
|
6
|
+
* @param {Record<string, unknown>} data - Top-level form data object
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function evaluateCondition(condition: {
|
|
10
|
+
field: string;
|
|
11
|
+
equals?: unknown;
|
|
12
|
+
notEquals?: unknown;
|
|
13
|
+
} | null | undefined, data: Record<string, unknown>): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/lib/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/forms",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.134",
|
|
4
4
|
"module": "src/index.js",
|
|
5
5
|
"author": "Jerry Thomas <me@jerrythomas.name>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
"access": "public"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"prepublishOnly": "tsc --project tsconfig.build.json",
|
|
12
|
+
"prepublishOnly": "cp ../../LICENSE . && tsc --project tsconfig.build.json",
|
|
13
|
+
"postpublish": "rm -f LICENSE",
|
|
13
14
|
"clean": "rm -rf dist",
|
|
14
15
|
"build": "bun clean && bun prepublishOnly"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"src/**/*.js",
|
|
18
19
|
"src/**/*.svelte",
|
|
19
|
-
"dist/**/*.d.ts"
|
|
20
|
+
"dist/**/*.d.ts",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
20
23
|
],
|
|
21
24
|
"exports": {
|
|
22
25
|
"./src": "./src",
|
package/src/FormRenderer.svelte
CHANGED
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
// Submit
|
|
164
164
|
submitting = true
|
|
165
165
|
try {
|
|
166
|
-
await onsubmit(
|
|
166
|
+
await onsubmit(formBuilder.getVisibleData(), { isValid: true, errors: [] })
|
|
167
167
|
formBuilder.snapshot()
|
|
168
168
|
} catch {
|
|
169
169
|
// Consumer handles errors in their onsubmit callback
|
|
@@ -193,11 +193,24 @@
|
|
|
193
193
|
{@render renderElement(element)}
|
|
194
194
|
{/each}
|
|
195
195
|
{#if actions}
|
|
196
|
-
{@render actions({
|
|
196
|
+
{@render actions({
|
|
197
|
+
submitting,
|
|
198
|
+
isValid: formBuilder.isValid,
|
|
199
|
+
isDirty: formBuilder.isDirty,
|
|
200
|
+
submit: handleSubmit,
|
|
201
|
+
reset: handleReset
|
|
202
|
+
})}
|
|
197
203
|
{:else}
|
|
198
204
|
<div data-form-actions>
|
|
199
|
-
<button
|
|
200
|
-
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
data-form-reset
|
|
208
|
+
disabled={!formBuilder.isDirty || submitting}
|
|
209
|
+
onclick={handleReset}>Reset</button
|
|
210
|
+
>
|
|
211
|
+
<button type="submit" data-form-submit disabled={!formBuilder.isDirty || submitting}
|
|
212
|
+
>Submit</button
|
|
213
|
+
>
|
|
201
214
|
</div>
|
|
202
215
|
{/if}
|
|
203
216
|
</form>
|
package/src/InfoField.svelte
CHANGED
|
@@ -8,11 +8,7 @@
|
|
|
8
8
|
const SEVERITY_ORDER = ['error', 'warning', 'info', 'success']
|
|
9
9
|
const SEVERITY_LABELS = { error: 'error', warning: 'warning', info: 'info', success: 'success' }
|
|
10
10
|
|
|
11
|
-
let {
|
|
12
|
-
items = [],
|
|
13
|
-
onclick = undefined,
|
|
14
|
-
class: className = ''
|
|
15
|
-
} = $props()
|
|
11
|
+
let { items = [], onclick = undefined, class: className = '' } = $props()
|
|
16
12
|
|
|
17
13
|
const grouped = $derived(
|
|
18
14
|
SEVERITY_ORDER.map((state) => ({
|
|
@@ -28,7 +24,11 @@
|
|
|
28
24
|
<div data-validation-group data-severity={group.state}>
|
|
29
25
|
<div data-validation-group-header>
|
|
30
26
|
<span data-validation-count>{group.items.length}</span>
|
|
31
|
-
<span
|
|
27
|
+
<span
|
|
28
|
+
>{group.items.length === 1
|
|
29
|
+
? SEVERITY_LABELS[group.state]
|
|
30
|
+
: `${SEVERITY_LABELS[group.state]}s`}</span
|
|
31
|
+
>
|
|
32
32
|
</div>
|
|
33
33
|
{#each group.items as item (item.path)}
|
|
34
34
|
{#if onclick}
|
|
@@ -5,14 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import DisplayValue from './DisplayValue.svelte'
|
|
7
7
|
|
|
8
|
-
let {
|
|
9
|
-
data = [],
|
|
10
|
-
fields = [],
|
|
11
|
-
select,
|
|
12
|
-
title,
|
|
13
|
-
onselect,
|
|
14
|
-
class: className = ''
|
|
15
|
-
} = $props()
|
|
8
|
+
let { data = [], fields = [], select, title, onselect, class: className = '' } = $props()
|
|
16
9
|
|
|
17
10
|
let selectedIndex = $state(-1)
|
|
18
11
|
let selectedIndices = $state(new Set())
|
|
@@ -4,12 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import DisplayValue from './DisplayValue.svelte'
|
|
6
6
|
|
|
7
|
-
let {
|
|
8
|
-
data = [],
|
|
9
|
-
fields = [],
|
|
10
|
-
title,
|
|
11
|
-
class: className = ''
|
|
12
|
-
} = $props()
|
|
7
|
+
let { data = [], fields = [], title, class: className = '' } = $props()
|
|
13
8
|
</script>
|
|
14
9
|
|
|
15
10
|
<div data-display-list class={className}>
|
|
@@ -5,14 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Table } from '@rokkit/ui'
|
|
7
7
|
|
|
8
|
-
let {
|
|
9
|
-
data = [],
|
|
10
|
-
columns = [],
|
|
11
|
-
select,
|
|
12
|
-
title,
|
|
13
|
-
onselect,
|
|
14
|
-
class: className = ''
|
|
15
|
-
} = $props()
|
|
8
|
+
let { data = [], columns = [], select, title, onselect, class: className = '' } = $props()
|
|
16
9
|
|
|
17
10
|
// Map display columns → Table columns with formatters
|
|
18
11
|
const tableColumns = $derived(
|
|
@@ -35,13 +35,18 @@
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
function typeDefault(type) {
|
|
38
|
-
return
|
|
38
|
+
return (
|
|
39
|
+
{ string: '', number: 0, integer: 0, boolean: false, array: [], object: {} }[type] ?? null
|
|
40
|
+
)
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
function createDefaultItem(schema) {
|
|
42
44
|
if (schema.type === 'object') {
|
|
43
45
|
return Object.fromEntries(
|
|
44
|
-
Object.entries(schema.properties ?? {}).map(([k, s]) => [
|
|
46
|
+
Object.entries(schema.properties ?? {}).map(([k, s]) => [
|
|
47
|
+
k,
|
|
48
|
+
s.default ?? typeDefault(s.type)
|
|
49
|
+
])
|
|
45
50
|
)
|
|
46
51
|
}
|
|
47
52
|
return schema.default ?? typeDefault(schema.type)
|
|
@@ -96,8 +101,8 @@
|
|
|
96
101
|
type="button"
|
|
97
102
|
data-array-editor-remove
|
|
98
103
|
{disabled}
|
|
99
|
-
onclick={() => removeItem(index)}
|
|
100
|
-
>
|
|
104
|
+
onclick={() => removeItem(index)}>Remove</button
|
|
105
|
+
>
|
|
101
106
|
{/if}
|
|
102
107
|
</div>
|
|
103
108
|
{/each}
|
|
@@ -68,6 +68,14 @@
|
|
|
68
68
|
{...rest}
|
|
69
69
|
/>
|
|
70
70
|
{#if variant !== 'default'}
|
|
71
|
-
<span
|
|
71
|
+
<span
|
|
72
|
+
class={icon}
|
|
73
|
+
data-checkbox-icon
|
|
74
|
+
role="button"
|
|
75
|
+
tabindex="0"
|
|
76
|
+
onclick={toggle}
|
|
77
|
+
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggle()}
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
></span>
|
|
72
80
|
{/if}
|
|
73
81
|
</div>
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
} = $props()
|
|
26
26
|
|
|
27
27
|
// Check if options include an empty string (used as "none" option)
|
|
28
|
-
const hasEmptyOption = $derived(
|
|
28
|
+
const hasEmptyOption = $derived(
|
|
29
|
+
options.some((opt) => opt === '' || (typeof opt === 'object' && opt?.value === ''))
|
|
30
|
+
)
|
|
29
31
|
|
|
30
32
|
// Filter out empty strings — Select + ProxyItem handles string options natively
|
|
31
33
|
const filteredOptions = $derived(
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
)
|
|
36
38
|
|
|
37
39
|
// Use placeholder for empty option, or provide a default clear label
|
|
38
|
-
const effectivePlaceholder = $derived(hasEmptyOption ?
|
|
40
|
+
const effectivePlaceholder = $derived(hasEmptyOption ? placeholder || 'None' : placeholder)
|
|
39
41
|
</script>
|
|
40
42
|
|
|
41
43
|
<Select
|
|
@@ -2,7 +2,11 @@ import { deriveSchemaFromValue } from './schema.js'
|
|
|
2
2
|
import { deriveLayoutFromValue } from './layout.js'
|
|
3
3
|
import { getSchemaWithLayout } from './fields.js'
|
|
4
4
|
import { createLookupManager } from './lookup.svelte.js'
|
|
5
|
-
import {
|
|
5
|
+
import { evaluateCondition } from './conditions.js'
|
|
6
|
+
import {
|
|
7
|
+
validateField as validateFieldValue,
|
|
8
|
+
validateAll as validateAllFields
|
|
9
|
+
} from './validation.js'
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Deep-clone a plain value (primitives, plain objects, arrays).
|
|
@@ -270,6 +274,9 @@ export class FormBuilder {
|
|
|
270
274
|
this.#data = updatedData
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
// Clear stale validation errors for fields that are now hidden
|
|
278
|
+
this.#clearHiddenValidation()
|
|
279
|
+
|
|
273
280
|
// Trigger dependent lookups if configured
|
|
274
281
|
if (triggerLookups && this.#lookupManager) {
|
|
275
282
|
// Clear dependent field values synchronously before lookup re-fetch
|
|
@@ -305,6 +312,22 @@ export class FormBuilder {
|
|
|
305
312
|
return current
|
|
306
313
|
}
|
|
307
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Clear validation errors for fields that are no longer visible
|
|
317
|
+
* @private
|
|
318
|
+
*/
|
|
319
|
+
#clearHiddenValidation() {
|
|
320
|
+
const visiblePaths = new Set(
|
|
321
|
+
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
322
|
+
)
|
|
323
|
+
const cleaned = Object.fromEntries(
|
|
324
|
+
Object.entries(this.#validation).filter(([path]) => visiblePaths.has(path))
|
|
325
|
+
)
|
|
326
|
+
if (Object.keys(cleaned).length !== Object.keys(this.#validation).length) {
|
|
327
|
+
this.#validation = cleaned
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
308
331
|
/**
|
|
309
332
|
* Build form elements from schema and layout using getSchemaWithLayout
|
|
310
333
|
* @private
|
|
@@ -354,6 +377,11 @@ export class FormBuilder {
|
|
|
354
377
|
props: separatorProps
|
|
355
378
|
})
|
|
356
379
|
} else {
|
|
380
|
+
// Check showWhen condition before processing scoped field
|
|
381
|
+
if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
357
385
|
// Extract key from scope
|
|
358
386
|
const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
|
|
359
387
|
const combinedEl = combinedMap.get(key)
|
|
@@ -455,8 +483,7 @@ export class FormBuilder {
|
|
|
455
483
|
)
|
|
456
484
|
|
|
457
485
|
// Group elements have top-level properties (label, etc.) from combineNestedElementsWithSchema
|
|
458
|
-
const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } =
|
|
459
|
-
element
|
|
486
|
+
const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } = element
|
|
460
487
|
|
|
461
488
|
return {
|
|
462
489
|
scope,
|
|
@@ -593,8 +620,31 @@ export class FormBuilder {
|
|
|
593
620
|
*/
|
|
594
621
|
validate() {
|
|
595
622
|
const results = validateAllFields(this.#data, this.#schema, this.#layout)
|
|
596
|
-
|
|
597
|
-
|
|
623
|
+
const visiblePaths = new Set(
|
|
624
|
+
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
625
|
+
)
|
|
626
|
+
const filtered = Object.fromEntries(
|
|
627
|
+
Object.entries(results).filter(([path]) => visiblePaths.has(path))
|
|
628
|
+
)
|
|
629
|
+
this.#validation = filtered
|
|
630
|
+
return filtered
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Get form data with hidden field values stripped out
|
|
635
|
+
* Hidden fields are those absent from this.elements (the derived list)
|
|
636
|
+
* Does not mutate this.#data
|
|
637
|
+
* @returns {Object} Filtered data containing only visible field keys
|
|
638
|
+
*/
|
|
639
|
+
getVisibleData() {
|
|
640
|
+
const visiblePaths = new Set(
|
|
641
|
+
this.elements
|
|
642
|
+
.filter((el) => el.scope)
|
|
643
|
+
.map((el) => el.scope.replace(/^#\//, ''))
|
|
644
|
+
)
|
|
645
|
+
return Object.fromEntries(
|
|
646
|
+
Object.entries(this.#data).filter(([key]) => visiblePaths.has(key))
|
|
647
|
+
)
|
|
598
648
|
}
|
|
599
649
|
|
|
600
650
|
/**
|
|
@@ -696,10 +746,7 @@ export class FormBuilder {
|
|
|
696
746
|
* @param {Set<string>} dirty - Accumulator set
|
|
697
747
|
*/
|
|
698
748
|
#collectDirtyFields(current, initial, prefix, dirty) {
|
|
699
|
-
const allKeys = new Set([
|
|
700
|
-
...Object.keys(current ?? {}),
|
|
701
|
-
...Object.keys(initial ?? {})
|
|
702
|
-
])
|
|
749
|
+
const allKeys = new Set([...Object.keys(current ?? {}), ...Object.keys(initial ?? {})])
|
|
703
750
|
|
|
704
751
|
for (const key of allKeys) {
|
|
705
752
|
const path = prefix ? `${prefix}/${key}` : key
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate a showWhen condition against current form data.
|
|
3
|
+
* Returns true if the field should be shown (condition met or absent).
|
|
4
|
+
*
|
|
5
|
+
* @param {{ field: string, equals?: unknown, notEquals?: unknown } | null | undefined} condition
|
|
6
|
+
* @param {Record<string, unknown>} data - Top-level form data object
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function evaluateCondition(condition, data) {
|
|
10
|
+
if (!condition) return true
|
|
11
|
+
const value = data[condition.field]
|
|
12
|
+
if ('equals' in condition) return value === condition.equals
|
|
13
|
+
if ('notEquals' in condition) return value !== condition.notEquals
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { evaluateCondition } from './conditions.js'
|
|
3
|
+
|
|
4
|
+
describe('evaluateCondition', () => {
|
|
5
|
+
it('returns true when condition is null', () => {
|
|
6
|
+
expect(evaluateCondition(null, { accountType: 'personal' })).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns true when condition is undefined', () => {
|
|
10
|
+
expect(evaluateCondition(undefined, { accountType: 'personal' })).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns true when neither operator is set', () => {
|
|
14
|
+
expect(evaluateCondition({ field: 'accountType' }, { accountType: 'business' })).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('equals operator', () => {
|
|
18
|
+
it('returns true when value matches', () => {
|
|
19
|
+
expect(
|
|
20
|
+
evaluateCondition({ field: 'accountType', equals: 'business' }, { accountType: 'business' })
|
|
21
|
+
).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns false when value does not match', () => {
|
|
25
|
+
expect(
|
|
26
|
+
evaluateCondition({ field: 'accountType', equals: 'business' }, { accountType: 'personal' })
|
|
27
|
+
).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns false when field is absent (undefined)', () => {
|
|
31
|
+
expect(evaluateCondition({ field: 'accountType', equals: 'business' }, {})).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('notEquals operator', () => {
|
|
36
|
+
it('returns true when value does not match', () => {
|
|
37
|
+
expect(
|
|
38
|
+
evaluateCondition(
|
|
39
|
+
{ field: 'accountType', notEquals: 'business' },
|
|
40
|
+
{ accountType: 'personal' }
|
|
41
|
+
)
|
|
42
|
+
).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns false when value matches', () => {
|
|
46
|
+
expect(
|
|
47
|
+
evaluateCondition(
|
|
48
|
+
{ field: 'accountType', notEquals: 'business' },
|
|
49
|
+
{ accountType: 'business' }
|
|
50
|
+
)
|
|
51
|
+
).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns true when field is absent (undefined !== value)', () => {
|
|
55
|
+
expect(evaluateCondition({ field: 'accountType', notEquals: 'business' }, {})).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
package/src/lib/fields.js
CHANGED
|
@@ -7,7 +7,6 @@ import { omit, pick } from 'ramda'
|
|
|
7
7
|
* @param {import('../types').LayoutSchema} attribute
|
|
8
8
|
*/
|
|
9
9
|
function combineArrayElementsWithSchema(element, attribute) {
|
|
10
|
-
|
|
11
10
|
const schema = getSchemaWithLayout(attribute.props.items, element.schema)
|
|
12
11
|
return {
|
|
13
12
|
...attribute,
|
|
@@ -76,7 +75,6 @@ function combineElementWithSchema(element, schema) {
|
|
|
76
75
|
let attribute = findAttributeByPath(scope, schema)
|
|
77
76
|
|
|
78
77
|
if (Array.isArray(element.elements)) {
|
|
79
|
-
|
|
80
78
|
attribute = combineNestedElementsWithSchema(element, attribute, schema)
|
|
81
79
|
} else if (element.schema || attribute.props?.type === 'array') {
|
|
82
80
|
attribute = combineArrayElementsWithSchema(element, attribute)
|
package/src/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { FormBuilder } from './builder.svelte.js'
|
|
2
|
+
export { evaluateCondition } from './conditions.js'
|
|
2
3
|
export { validateField, validateAll, createMessage, patterns } from './validation.js'
|
|
3
4
|
export { default as FormRenderer } from './FormRenderer.svelte'
|
|
4
5
|
export { default as Input } from './Input.svelte'
|
package/src/lib/layout.js
CHANGED
|
@@ -13,7 +13,7 @@ function deriveElementLayout(val, scope, label) {
|
|
|
13
13
|
return {
|
|
14
14
|
title: label,
|
|
15
15
|
scope: path,
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
...deriveLayoutFromValue(val, path)
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -40,7 +40,6 @@ function deriveObjectLayout(value, scope) {
|
|
|
40
40
|
* @returns {import('../types').DataLayout}
|
|
41
41
|
*/
|
|
42
42
|
function deriveArrayLayout(value, scope) {
|
|
43
|
-
|
|
44
43
|
const schema = deriveLayoutFromValue(value[0], '#')
|
|
45
44
|
return {
|
|
46
45
|
scope,
|
package/src/lib/schema.js
CHANGED