@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 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 rendering for Svelte 5. Generate dynamic forms from data, schema, and layout definitions.
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
- ## Core Concepts
13
+ ## Overview
12
14
 
13
- ### FormBuilder
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
- Takes `(data, schema, layout)` and produces a reactive `elements[]` array. Each element describes a form field with its type, current value, and merged properties.
17
+ ## Usage
16
18
 
17
- ```js
18
- import { FormBuilder } from '@rokkit/forms'
19
+ ### Minimal — auto-derive everything
19
20
 
20
- const builder = new FormBuilder(
21
- { name: 'Alice', age: 30 }, // data
22
- schema, // optional auto-derived from data
23
- layout // optional — auto-derived from data
24
- )
21
+ ```svelte
22
+ <script>
23
+ import { FormRenderer } from '@rokkit/forms'
25
24
 
26
- // builder.elements [{ scope, type, value, props }, ...]
25
+ let data = $state({ name: '', age: 0, active: true })
26
+ </script>
27
+
28
+ <FormRenderer bind:data />
27
29
  ```
28
30
 
29
- If `schema` is `null`, it is auto-derived from the data using `deriveSchemaFromValue()`. If `layout` is `null`, it is auto-derived using `deriveLayoutFromValue()`.
31
+ Schema and layout are inferred from the data shape automatically.
30
32
 
31
- ### Schema
33
+ ### With explicit schema and layout
32
34
 
33
- JSON-Schema-like type definitions. Supports: `string`, `number`, `integer`, `boolean`, `array`, `object`, `date`.
35
+ ```svelte
36
+ <script>
37
+ import { FormRenderer } from '@rokkit/forms'
34
38
 
35
- ```js
36
- const schema = {
37
- type: 'object',
38
- properties: {
39
- name: { type: 'string' },
40
- size: { type: 'string', enum: ['sm', 'md', 'lg'] },
41
- count: { type: 'integer', min: 0, max: 100 },
42
- active: { type: 'boolean' }
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
- ### Layout
66
+ ### FormBuilder (imperative)
48
67
 
49
- Controls rendering order, grouping, labels, and component-specific props. Uses JSON Pointer scopes (`#/fieldName`).
68
+ Use `FormBuilder` directly when you need to drive forms outside of `FormRenderer`:
50
69
 
51
70
  ```js
52
- const layout = {
53
- type: 'vertical',
54
- elements: [
55
- { scope: '#/name', label: 'Full Name', props: { placeholder: 'Enter name' } },
56
- { scope: '#/size', label: 'Size', props: { options: ['sm', 'md', 'lg'] } },
57
- { scope: '#/count', label: 'Count' },
58
- { type: 'separator' },
59
- { scope: '#/active', label: 'Active' },
60
- { scope: '#/total', label: 'Total', readonly: true }
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
- ### Type Resolution
82
+ ### Validation
66
83
 
67
- The FormBuilder determines input type from the schema:
84
+ ```js
85
+ import { validateField, validateAll, patterns } from '@rokkit/forms'
68
86
 
69
- | Schema type | Condition | Input type |
70
- |---|---|---|
71
- | `string` | has `enum` or `options` | `select` |
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
- ### Layout Props
91
+ ### Dependent lookups with createLookup
80
92
 
81
- The `props` field in layout elements passes component-specific configuration:
93
+ `createLookup` creates a reactive dropdown data source that can depend on other field values:
82
94
 
83
95
  ```js
84
- // Select with string options
85
- { scope: '#/size', props: { options: ['sm', 'md', 'lg'] } }
96
+ import { createLookup } from '@rokkit/forms'
86
97
 
87
- // Select with object options and field mapping
88
- { scope: '#/color', props: {
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
- // Readonly info display
94
- { scope: '#/total', readonly: true }
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
- // Separator (no scope)
97
- { type: 'separator' }
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
- ## Components
101
-
102
- ### FormRenderer
116
+ ### Custom field rendering
103
117
 
104
- Renders a complete form from data + schema + layout:
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
- let data = $state({ name: '', size: 'md', active: true })
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
- <MyCustomInput value={element.value} {...element.props} />
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
- Set `override: true` on layout elements to route them to the `child` snippet.
135
-
136
- ### InputField
141
+ ## Type resolution
137
142
 
138
- Wraps an input with label, description, and validation message:
143
+ `FormBuilder` maps schema types to input types automatically:
139
144
 
140
- ```svelte
141
- <InputField name="email" type="email" value={email} label="Email" onchange={handleChange} />
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
- ### Input
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
- ```svelte
164
- <InfoField label="Total" value={42} />
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
- ## Lib Utilities
165
+ ## Utilities
168
166
 
169
167
  ```js
170
- import { getSchemaWithLayout, findAttributeByPath } from '@rokkit/forms'
171
- import { deriveSchemaFromValue } from '@rokkit/forms/lib'
172
- import { deriveLayoutFromValue } from '@rokkit/forms/lib'
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
- Form field styles are provided by `@rokkit/themes`. The components use data-attribute selectors:
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
- For fields with a small number of options, a Toggle (radio-style button group) can replace Select for better UX:
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
- ```js
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
- The `FormBuilder` already has `lookupManager` infrastructure for this pattern. The remaining work is:
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 {};
@@ -1,4 +1,5 @@
1
1
  export { FormBuilder } from "./builder.svelte.js";
2
+ export { evaluateCondition } from "./conditions.js";
2
3
  export { default as FormRenderer } from "./FormRenderer.svelte";
3
4
  export { default as Input } from "./Input.svelte";
4
5
  export * from "./schema.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/forms",
3
- "version": "1.0.0-next.132",
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",
@@ -163,7 +163,7 @@
163
163
  // Submit
164
164
  submitting = true
165
165
  try {
166
- await onsubmit(data, { isValid: true, errors: [] })
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({ submitting, isValid: formBuilder.isValid, isDirty: formBuilder.isDirty, submit: handleSubmit, reset: handleReset })}
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 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>
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>
@@ -1,11 +1,5 @@
1
1
  <script>
2
- let {
3
- class: className,
4
- name,
5
- value,
6
- label,
7
- description
8
- } = $props()
2
+ let { class: className, name, value, label, description } = $props()
9
3
  </script>
10
4
 
11
5
  <div
@@ -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>{group.items.length === 1 ? SEVERITY_LABELS[group.state] : `${SEVERITY_LABELS[group.state]}s`}</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 { string: '', number: 0, integer: 0, boolean: false, array: [], object: {} }[type] ?? null
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]) => [k, s.default ?? typeDefault(s.type)])
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
- >Remove</button>
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 class={icon} data-checkbox-icon role="button" tabindex="0" onclick={toggle} onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggle()} aria-hidden="true"></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(options.some((opt) => opt === '' || (typeof opt === 'object' && opt?.value === '')))
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 ? (placeholder || 'None') : placeholder)
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 { validateField as validateFieldValue, validateAll as validateAllFields } from './validation.js'
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
- this.#validation = results
597
- return results
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
@@ -9,7 +9,6 @@ import { typeOf } from '@rokkit/data'
9
9
  function deriveObjectProperties(data) {
10
10
  const properties = {}
11
11
  for (const [key, value] of Object.entries(data)) {
12
-
13
12
  properties[key] = deriveSchemaFromValue(value)
14
13
  }
15
14
  return properties