@rokkit/forms 1.7.0 → 1.8.0

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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The input type for a field: an explicit renderer, then a non-basic format (`email`, `date`…),
3
+ * then its schema type — a bounded number is a range, a string with choices a select. May set
4
+ * `props.options` from an enum.
5
+ * @param {Record<string, any>} props
6
+ * @returns {string}
7
+ */
8
+ export function resolveInputType(props: Record<string, any>): string;
9
+ /**
10
+ * The FormElements for the given layout elements (a step's, or a flat layout's).
11
+ *
12
+ * A field hidden by `showWhen` has no element. If building throws — a layout scope the schema
13
+ * does not know is one way — the whole layout falls back to plain text inputs rather than
14
+ * rendering nothing.
15
+ *
16
+ * @param {Object[]} layoutElements
17
+ * @param {ElementContext} ctx
18
+ */
19
+ export function buildElements(layoutElements: Object[], ctx: ElementContext): any;
20
+ /**
21
+ * Layout + schema + state → the `FormElement`s a `FormRenderer` draws. Pure: everything it needs
22
+ * arrives in `ctx`, so it is testable without a FormBuilder and derivable as one `$derived`.
23
+ */
24
+ export type ElementContext = {
25
+ schema: Object;
26
+ /**
27
+ * - the whole layout (the fallback path renders all of it)
28
+ */
29
+ layout: Object;
30
+ data: Record<string, unknown>;
31
+ value: (path: string) => unknown;
32
+ /**
33
+ * - validation message for a field
34
+ */
35
+ message: (path: string) => Object | null;
36
+ dirty: (path: string) => boolean;
37
+ /**
38
+ * - fold lookup state into props
39
+ */
40
+ applyLookup: (path: string, props: Object) => void;
41
+ };
42
+ export type FormElement = {
43
+ /**
44
+ * - JSON Pointer path (e.g., '#/email', '#/user/name')
45
+ */
46
+ scope: string;
47
+ /**
48
+ * - Input type (text, number, range, checkbox, select, etc.)
49
+ */
50
+ type: string;
51
+ /**
52
+ * - Current value from data
53
+ */
54
+ value: any;
55
+ /**
56
+ * - Whether to use custom child snippet (from layout)
57
+ */
58
+ override: boolean;
59
+ /**
60
+ * - Merged properties from schema + layout + validation
61
+ */
62
+ props: {
63
+ label?: string | undefined;
64
+ description?: string | undefined;
65
+ placeholder?: string | undefined;
66
+ required?: boolean | undefined;
67
+ min?: number | undefined;
68
+ max?: number | undefined;
69
+ message?: {
70
+ /**
71
+ * - Message state: 'error', 'warning', 'info', 'success'
72
+ */
73
+ state?: string | undefined;
74
+ /**
75
+ * - Message text content
76
+ */
77
+ text?: string | undefined;
78
+ } | undefined;
79
+ dirty?: boolean | undefined;
80
+ };
81
+ };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Layout + schema + state → the `FormElement`s a `FormRenderer` draws. Pure: everything it needs
3
+ * arrives in `ctx`, so it is testable without a FormBuilder and derivable as one `$derived`.
4
+ *
5
+ * @typedef {Object} ElementContext
6
+ * @property {Object} schema
7
+ * @property {Object} layout - the whole layout (the fallback path renders all of it)
8
+ * @property {Record<string, unknown>} data
9
+ * @property {(path: string) => unknown} value
10
+ * @property {(path: string) => Object | null} message - validation message for a field
11
+ * @property {(path: string) => boolean} dirty
12
+ * @property {(path: string, props: Object) => void} applyLookup - fold lookup state into props
13
+ */
14
+ /**
15
+ * @typedef {Object} FormElement
16
+ * @property {string} scope - JSON Pointer path (e.g., '#/email', '#/user/name')
17
+ * @property {string} type - Input type (text, number, range, checkbox, select, etc.)
18
+ * @property {any} value - Current value from data
19
+ * @property {boolean} override - Whether to use custom child snippet (from layout)
20
+ * @property {Object} props - Merged properties from schema + layout + validation
21
+ * @property {string} [props.label] - Display label (from layout)
22
+ * @property {string} [props.description] - Help text (from layout)
23
+ * @property {string} [props.placeholder] - Placeholder text (from layout)
24
+ * @property {boolean} [props.required] - Required flag (from schema)
25
+ * @property {number} [props.min] - Minimum value (from schema)
26
+ * @property {number} [props.max] - Maximum value (from schema)
27
+ * @property {Object} [props.message] - Validation message object
28
+ * @property {string} [props.message.state] - Message state: 'error', 'warning', 'info', 'success'
29
+ * @property {string} [props.message.text] - Message text content
30
+ * @property {boolean} [props.dirty] - Whether field value differs from initial
31
+ */
32
+
33
+ import { getSchemaWithLayout } from './fields.js'
34
+ import { evaluateCondition } from './conditions.js'
35
+
36
+ /** Schema types with a fixed input type. */
37
+ const SCHEMA_TYPE_MAP = { boolean: 'checkbox', array: 'array' }
38
+
39
+ const pathOf = (scope) => scope.replace(/^#\//, '')
40
+
41
+ function stringInputType(props) {
42
+ if (!props.enum && !props.options) return 'text'
43
+ // A select reads `options`; an enum is the options when none are given.
44
+ if (Array.isArray(props.enum) && !props.options) props.options = props.enum
45
+ return 'select'
46
+ }
47
+
48
+ function schemaInputType(props) {
49
+ if (props.type === 'number' || props.type === 'integer') {
50
+ return props.min !== undefined && props.max !== undefined ? 'range' : 'number'
51
+ }
52
+ if (props.type === 'string') return stringInputType(props)
53
+ return SCHEMA_TYPE_MAP[props.type] ?? 'text'
54
+ }
55
+
56
+ /**
57
+ * The input type for a field: an explicit renderer, then a non-basic format (`email`, `date`…),
58
+ * then its schema type — a bounded number is a range, a string with choices a select. May set
59
+ * `props.options` from an enum.
60
+ * @param {Record<string, any>} props
61
+ * @returns {string}
62
+ */
63
+ export function resolveInputType(props) {
64
+ if (props.renderer) return props.renderer
65
+ if (props.format && !['text', 'number'].includes(props.format)) return props.format
66
+ return schemaInputType(props)
67
+ }
68
+
69
+ /** A display element (`display-*`): its own type, its bound value, no input. */
70
+ function displayElement(layoutEl, ctx) {
71
+ const { type, scope: rawScope, ...props } = layoutEl
72
+ const scope = rawScope ?? null
73
+ const value = scope ? ctx.value(pathOf(scope)) : null
74
+ return { type, scope, value, override: false, props }
75
+ }
76
+
77
+ /** An unscoped element — a separator, a spacer, a heading. */
78
+ function unscopedElement(layoutEl) {
79
+ const { type, ...props } = layoutEl
80
+ return { type: type ?? 'separator', scope: null, value: null, override: false, props }
81
+ }
82
+
83
+ function groupElement(element, path, scope, ctx) {
84
+ const children = element.elements.map((child) => fieldElement(child, ctx, path))
85
+ const {
86
+ key: _key,
87
+ elements: _elements,
88
+ override: _override,
89
+ props: groupProps,
90
+ ...topLevel
91
+ } = element
92
+ return {
93
+ scope,
94
+ type: 'group',
95
+ value: ctx.value(path),
96
+ override: element.override || false,
97
+ props: { ...topLevel, ...groupProps, elements: children, message: ctx.message(path) }
98
+ }
99
+ }
100
+
101
+ function readonlyElement(element, path, scope, ctx) {
102
+ return {
103
+ scope,
104
+ type: 'info',
105
+ value: ctx.value(path),
106
+ override: element.override || false,
107
+ props: { ...element.props, type: 'info', message: ctx.message(path) }
108
+ }
109
+ }
110
+
111
+ function inputElement(element, path, scope, ctx) {
112
+ // `override` is authored on the LAYOUT element, and getSchemaWithLayout folds unknown layout
113
+ // keys into `props` — read only from `element.override` it was always false and the child
114
+ // snippet never rendered. Take it from either place; keep it out of the input's props.
115
+ const { override: propsOverride, ...props } = element.props ?? {}
116
+ const type = resolveInputType(props)
117
+ const finalProps = { ...props, type, message: ctx.message(path), dirty: ctx.dirty(path) }
118
+ ctx.applyLookup(path, finalProps)
119
+ return {
120
+ scope,
121
+ type,
122
+ value: ctx.value(path),
123
+ override: element.override || propsOverride || false,
124
+ props: finalProps
125
+ }
126
+ }
127
+
128
+ /** A combined schema+layout element → FormElement. Null for one without a key. */
129
+ function fieldElement(element, ctx, parentPath = '') {
130
+ /* v8 ignore start -- unreachable: every element arrives from getSchemaWithLayout's output,
131
+ which combinedByKey keeps only when keyed and whose group children are keyed too. Kept as
132
+ the guard FormBuilder always had. `ignore next` does not fire on a one-line `if`. */
133
+ if (!element.key) return null
134
+ /* v8 ignore stop */
135
+ const path = parentPath ? `${parentPath}/${element.key}` : element.key
136
+ const scope = `#/${path}`
137
+ if (element.elements) return groupElement(element, path, scope, ctx)
138
+ if (element.props.readonly) return readonlyElement(element, path, scope, ctx)
139
+ return inputElement(element, path, scope, ctx)
140
+ }
141
+
142
+ /** The schema-merged element for each scoped, non-display layout element, by field key. */
143
+ function combinedByKey(layoutElements, ctx) {
144
+ const elements = layoutElements.filter((el) => el.scope && !el.type?.startsWith('display-'))
145
+ const combined = getSchemaWithLayout(ctx.schema, { ...ctx.layout, elements })
146
+ return new Map((combined.elements ?? []).filter((el) => el.key).map((el) => [el.key, el]))
147
+ }
148
+
149
+ function scopedElement(layoutEl, combined, ctx) {
150
+ if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, ctx.data)) return null
151
+ const merged = combined.get(pathOf(layoutEl.scope).split('/').pop())
152
+ return merged ? fieldElement(merged, ctx) : null
153
+ }
154
+
155
+ function layoutElement(layoutEl, combined, ctx) {
156
+ if (layoutEl.type?.startsWith('display-')) return displayElement(layoutEl, ctx)
157
+ if (!layoutEl.scope) return unscopedElement(layoutEl)
158
+ return scopedElement(layoutEl, combined, ctx)
159
+ }
160
+
161
+ /** Last resort when building fails: every scoped layout element as a plain text input. */
162
+ function plainElements(ctx) {
163
+ return (ctx.layout.elements ?? [])
164
+ .filter((el) => el.scope)
165
+ .map(({ scope, label, override = false, ...layoutProps }) => {
166
+ const path = pathOf(scope)
167
+ const props = {
168
+ label: label || path,
169
+ ...layoutProps,
170
+ message: ctx.message(path),
171
+ dirty: ctx.dirty(path),
172
+ type: 'text'
173
+ }
174
+ return { scope, type: 'text', value: ctx.value(path), override, props }
175
+ })
176
+ }
177
+
178
+ /**
179
+ * The FormElements for the given layout elements (a step's, or a flat layout's).
180
+ *
181
+ * A field hidden by `showWhen` has no element. If building throws — a layout scope the schema
182
+ * does not know is one way — the whole layout falls back to plain text inputs rather than
183
+ * rendering nothing.
184
+ *
185
+ * @param {Object[]} layoutElements
186
+ * @param {ElementContext} ctx
187
+ */
188
+ export function buildElements(layoutElements, ctx) {
189
+ try {
190
+ const combined = combinedByKey(layoutElements, ctx)
191
+ return layoutElements.map((el) => layoutElement(el, combined, ctx)).filter(Boolean)
192
+ } catch (error) {
193
+ console.warn('Failed to build elements:', error) // eslint-disable-line no-console
194
+ return plainElements(ctx)
195
+ }
196
+ }
@@ -5,3 +5,16 @@
5
5
  * @returns {import('../types').DataSchema}
6
6
  */
7
7
  export function deriveSchemaFromValue(data: any): import("../types").DataSchema;
8
+ /**
9
+ * A field's schema by slash path (`'addr/city'`), or null.
10
+ *
11
+ * Each step looks in the current schema's `properties` first — how JSON Schema nests an object's
12
+ * fields — and falls back to the key directly on it, the flat form some schemas here use
13
+ * (`properties.a.b`). `properties` first, so a nested field named like a schema keyword (`type`)
14
+ * resolves to the field, not to its parent's keyword.
15
+ *
16
+ * @param {Object | null | undefined} schema
17
+ * @param {string} path
18
+ * @returns {Object | null}
19
+ */
20
+ export function schemaAt(schema: Object | null | undefined, path: string): Object | null;
@@ -29,3 +29,30 @@ export function deriveSchemaFromValue(data) {
29
29
  }
30
30
  return schema
31
31
  }
32
+
33
+ const isSchemaObject = (value) => Boolean(value) && typeof value === 'object'
34
+
35
+ /** One step down: the JSON Schema child, else the flat one. */
36
+ function childSchema(parent, key) {
37
+ if (!isSchemaObject(parent)) return undefined
38
+ return parent.properties?.[key] ?? parent[key]
39
+ }
40
+
41
+ /**
42
+ * A field's schema by slash path (`'addr/city'`), or null.
43
+ *
44
+ * Each step looks in the current schema's `properties` first — how JSON Schema nests an object's
45
+ * fields — and falls back to the key directly on it, the flat form some schemas here use
46
+ * (`properties.a.b`). `properties` first, so a nested field named like a schema keyword (`type`)
47
+ * resolves to the field, not to its parent's keyword.
48
+ *
49
+ * @param {Object | null | undefined} schema
50
+ * @param {string} path
51
+ * @returns {Object | null}
52
+ */
53
+ export function schemaAt(schema, path) {
54
+ // The root's own keys are the schema's keywords, never fields: only its `properties` count.
55
+ let current = schema?.properties?.[path.split('/')[0]]
56
+ for (const key of path.split('/').slice(1)) current = childSchema(current, key)
57
+ return isSchemaObject(current) ? current : null
58
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What the form IS: its schema (the fields and their rules) and its layout (how they are
3
+ * arranged), each derivable from the other or from the data.
4
+ *
5
+ * The setters carry the derivation rules, so a caller can set either at any time:
6
+ * - a schema set explicitly is the source of truth for which fields exist — a layout that was
7
+ * only ever derived (perhaps from empty data) is re-derived from it; an explicit, non-empty
8
+ * layout is kept;
9
+ * - a null schema derives from the current data;
10
+ * - a null layout derives from the schema when it declares fields — so declared fields render
11
+ * even when the data starts as `{}`, the norm for LLM-generated and reset forms — else from
12
+ * the data.
13
+ */
14
+ export class FormDefinition {
15
+ /**
16
+ * @param {import('./FormValues.svelte.js').FormValues} values
17
+ * @param {Object|null} schema
18
+ * @param {Object|null} layout
19
+ */
20
+ constructor(values: import("./FormValues.svelte.js").FormValues, schema: Object | null, layout: Object | null);
21
+ set schema(value: {});
22
+ get schema(): {};
23
+ set layout(value: {});
24
+ get layout(): {};
25
+ /** Schema merged with the layout's scoped elements. */
26
+ get combined(): any;
27
+ /** The schema of one field by slash path, or null — see `schemaAt`. */
28
+ fieldSchema(path: any): Object | null;
29
+ /** A field's display label: its layout element's label, then title, then the path itself. */
30
+ fieldLabel(path: any): any;
31
+ #private;
32
+ }
@@ -0,0 +1,83 @@
1
+ import { deriveSchemaFromValue, schemaAt } from '../schema.js'
2
+ import { deriveLayoutFromValue, deriveLayoutFromSchema } from '../layout.js'
3
+ import { getSchemaWithLayout } from '../fields.js'
4
+
5
+ const hasFields = (schema) =>
6
+ Boolean(
7
+ schema &&
8
+ typeof schema === 'object' &&
9
+ schema.properties &&
10
+ typeof schema.properties === 'object' &&
11
+ Object.keys(schema.properties).length > 0
12
+ )
13
+
14
+ const hasElements = (layout) =>
15
+ Boolean(layout && Array.isArray(layout.elements) && layout.elements.length > 0)
16
+
17
+ /**
18
+ * What the form IS: its schema (the fields and their rules) and its layout (how they are
19
+ * arranged), each derivable from the other or from the data.
20
+ *
21
+ * The setters carry the derivation rules, so a caller can set either at any time:
22
+ * - a schema set explicitly is the source of truth for which fields exist — a layout that was
23
+ * only ever derived (perhaps from empty data) is re-derived from it; an explicit, non-empty
24
+ * layout is kept;
25
+ * - a null schema derives from the current data;
26
+ * - a null layout derives from the schema when it declares fields — so declared fields render
27
+ * even when the data starts as `{}`, the norm for LLM-generated and reset forms — else from
28
+ * the data.
29
+ */
30
+ export class FormDefinition {
31
+ #values
32
+ #schema = $state({})
33
+ #layout = $state({})
34
+
35
+ /**
36
+ * @param {import('./FormValues.svelte.js').FormValues} values
37
+ * @param {Object|null} schema
38
+ * @param {Object|null} layout
39
+ */
40
+ constructor(values, schema, layout) {
41
+ this.#values = values
42
+ this.schema = schema
43
+ this.layout = layout
44
+ }
45
+
46
+ get schema() {
47
+ return this.#schema
48
+ }
49
+ set schema(value) {
50
+ this.#schema = value ?? deriveSchemaFromValue(this.#values.data)
51
+ if (!hasElements(this.#layout)) this.#layout = deriveLayoutFromSchema(this.#schema)
52
+ }
53
+
54
+ get layout() {
55
+ return this.#layout
56
+ }
57
+ set layout(value) {
58
+ if (value) {
59
+ this.#layout = value
60
+ return
61
+ }
62
+ this.#layout = hasFields(this.#schema)
63
+ ? deriveLayoutFromSchema(this.#schema)
64
+ : deriveLayoutFromValue(this.#values.data)
65
+ }
66
+
67
+ /** Schema merged with the layout's scoped elements. */
68
+ get combined() {
69
+ const elements = (this.#layout?.elements ?? []).filter((el) => el.scope)
70
+ return getSchemaWithLayout(this.#schema, { ...this.#layout, elements })
71
+ }
72
+
73
+ /** The schema of one field by slash path, or null — see `schemaAt`. */
74
+ fieldSchema(path) {
75
+ return schemaAt(this.#schema, path)
76
+ }
77
+
78
+ /** A field's display label: its layout element's label, then title, then the path itself. */
79
+ fieldLabel(path) {
80
+ const el = this.#layout?.elements?.find((e) => e.scope === `#/${path}`)
81
+ return el?.label || (el?.title ?? path)
82
+ }
83
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Fields whose options come from elsewhere — a URL, an async hook, or a filtered source — and
3
+ * the fields they depend on.
4
+ *
5
+ * A form with no lookups has no manager at all, and every question here answers safely.
6
+ */
7
+ export class FormLookups {
8
+ /**
9
+ * @param {import('./FormValues.svelte.js').FormValues} values
10
+ * @param {Object<string, import('../lookup.svelte.js').LookupConfig>} lookups
11
+ */
12
+ constructor(values: import("./FormValues.svelte.js").FormValues, lookups?: {
13
+ [x: string]: import("../lookup.svelte.js").LookupConfig;
14
+ });
15
+ get manager(): Object | null;
16
+ /** Replace the lookups. Always builds a manager, even for none — as `setLookups` always has. */
17
+ configure(lookups: any): void;
18
+ /** A field's options, loading, error, fields and disabled — or null when it has no lookup. */
19
+ state(path: any): {
20
+ options: any;
21
+ loading: any;
22
+ error: any;
23
+ fields: any;
24
+ disabled: any;
25
+ } | null;
26
+ /** Disabled because a field it depends on is not set yet. */
27
+ isDisabled(path: any): any;
28
+ has(path: any): any;
29
+ refresh(path: any): Promise<void>;
30
+ initialize(): Promise<void>;
31
+ /**
32
+ * A field changed: clear the (top-level) values of lookups that depend on it — their options
33
+ * are about to change under them — then re-fetch those lookups.
34
+ */
35
+ fieldChanged(path: any): void;
36
+ /** Fold a field's lookup state into an element's props (mutates them). */
37
+ applyTo(path: any, props: any): void;
38
+ #private;
39
+ }
@@ -0,0 +1,93 @@
1
+ import { createLookupManager } from '../lookup.svelte.js'
2
+
3
+ /**
4
+ * Fields whose options come from elsewhere — a URL, an async hook, or a filtered source — and
5
+ * the fields they depend on.
6
+ *
7
+ * A form with no lookups has no manager at all, and every question here answers safely.
8
+ */
9
+ export class FormLookups {
10
+ #values
11
+ /** @type {ReturnType<typeof createLookupManager> | null} */
12
+ #manager = $state(null)
13
+
14
+ /**
15
+ * @param {import('./FormValues.svelte.js').FormValues} values
16
+ * @param {Object<string, import('../lookup.svelte.js').LookupConfig>} lookups
17
+ */
18
+ constructor(values, lookups = {}) {
19
+ this.#values = values
20
+ if (Object.keys(lookups).length > 0) this.#manager = createLookupManager(lookups)
21
+ }
22
+
23
+ get manager() {
24
+ return this.#manager
25
+ }
26
+
27
+ /** Replace the lookups. Always builds a manager, even for none — as `setLookups` always has. */
28
+ configure(lookups) {
29
+ this.#manager = createLookupManager(lookups)
30
+ }
31
+
32
+ /** @param {string} path */
33
+ #lookup(path) {
34
+ return this.#manager?.getLookup(path)
35
+ }
36
+
37
+ /** A field's options, loading, error, fields and disabled — or null when it has no lookup. */
38
+ state(path) {
39
+ const lookup = this.#lookup(path)
40
+ if (!lookup) return null
41
+ const { options, loading, error, fields, disabled } = lookup
42
+ return { options, loading, error, fields, disabled }
43
+ }
44
+
45
+ /** Disabled because a field it depends on is not set yet. */
46
+ isDisabled(path) {
47
+ return this.#lookup(path)?.disabled ?? false
48
+ }
49
+
50
+ has(path) {
51
+ return this.#manager?.hasLookup(path) ?? false
52
+ }
53
+
54
+ async refresh(path) {
55
+ await this.#lookup(path)?.fetch(this.#values.data)
56
+ }
57
+
58
+ async initialize() {
59
+ await this.#manager?.initialize(this.#values.data)
60
+ }
61
+
62
+ /**
63
+ * A field changed: clear the (top-level) values of lookups that depend on it — their options
64
+ * are about to change under them — then re-fetch those lookups.
65
+ */
66
+ fieldChanged(path) {
67
+ if (!this.#manager) return
68
+ for (const [depPath, lookup] of this.#manager.lookups) {
69
+ if (lookup.dependsOn.includes(path) && !depPath.includes('/')) this.#values.set(depPath, null)
70
+ }
71
+ this.#manager.handleFieldChange(path, this.#values.data)
72
+ }
73
+
74
+ /** Fold a field's lookup state into an element's props (mutates them). */
75
+ applyTo(path, props) {
76
+ const state = this.state(path)
77
+ if (!state) return
78
+ applyOptions(state, props)
79
+ applyAvailability(state, props)
80
+ }
81
+ }
82
+
83
+ /** Options and loading. */
84
+ function applyOptions(state, props) {
85
+ if (state.options?.length > 0) props.options = state.options
86
+ if (state.loading) props.loading = true
87
+ }
88
+
89
+ /** Disabled, and the option field mapping unless the element sets its own. */
90
+ function applyAvailability(state, props) {
91
+ if (state.disabled) props.disabled = true
92
+ if (state.fields && !props.fields) props.fields = state.fields
93
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Every scoped field path in a list of layout elements, nested groups included.
3
+ * @param {Object[] | undefined} elements
4
+ * @returns {string[]}
5
+ */
6
+ export function stepPaths(elements: Object[] | undefined): string[];
7
+ /**
8
+ * A multi-step (wizard) form: a layout whose top-level elements are `{ type: 'step' }`. Only the
9
+ * active step's elements render. A flat layout has no steps, and all of it is active.
10
+ *
11
+ * Moving forward is `advance()` here; VALIDATING before moving is the form's job, which is why
12
+ * `FormBuilder.next()` checks the step and then calls this.
13
+ */
14
+ export class FormSteps {
15
+ /** @param {import('./FormDefinition.svelte.js').FormDefinition} definition */
16
+ constructor(definition: import("./FormDefinition.svelte.js").FormDefinition);
17
+ get isMultiStep(): any;
18
+ get total(): any;
19
+ get current(): number;
20
+ canAdvance: boolean;
21
+ /** The step element at an index, or null when there is none. */
22
+ step(index: any): any;
23
+ /** The layout elements that render now: the active step's, or all of a flat layout's. */
24
+ get activeElements(): any;
25
+ /** Every step's elements, flattened — the whole form, for validating all of it at once. */
26
+ get allStepElements(): any;
27
+ advance(): boolean;
28
+ back(): boolean;
29
+ /** Return to a step already visited. Forward is only reachable through `next()`. */
30
+ goTo(index: any): void;
31
+ #private;
32
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Every scoped field path in a list of layout elements, nested groups included.
3
+ * @param {Object[] | undefined} elements
4
+ * @returns {string[]}
5
+ */
6
+ export function stepPaths(elements) {
7
+ const paths = []
8
+ for (const el of elements ?? []) {
9
+ if (el.scope) paths.push(el.scope.replace(/^#\//, ''))
10
+ if (el.elements) paths.push(...stepPaths(el.elements))
11
+ }
12
+ return paths
13
+ }
14
+
15
+ /**
16
+ * A multi-step (wizard) form: a layout whose top-level elements are `{ type: 'step' }`. Only the
17
+ * active step's elements render. A flat layout has no steps, and all of it is active.
18
+ *
19
+ * Moving forward is `advance()` here; VALIDATING before moving is the form's job, which is why
20
+ * `FormBuilder.next()` checks the step and then calls this.
21
+ */
22
+ export class FormSteps {
23
+ #definition
24
+ #current = $state(0)
25
+
26
+ /** @param {import('./FormDefinition.svelte.js').FormDefinition} definition */
27
+ constructor(definition) {
28
+ this.#definition = definition
29
+ }
30
+
31
+ get #elements() {
32
+ return this.#definition.layout?.elements ?? []
33
+ }
34
+
35
+ get isMultiStep() {
36
+ return this.#elements.some((el) => el.type === 'step')
37
+ }
38
+ get total() {
39
+ return this.#elements.filter((el) => el.type === 'step').length
40
+ }
41
+ get current() {
42
+ return this.#current
43
+ }
44
+ canAdvance = $derived(this.#current < this.total - 1)
45
+
46
+ /** The step element at an index, or null when there is none. */
47
+ step(index) {
48
+ const el = this.#elements[index]
49
+ return el?.type === 'step' ? el : null
50
+ }
51
+
52
+ /** The layout elements that render now: the active step's, or all of a flat layout's. */
53
+ get activeElements() {
54
+ if (!this.isMultiStep) return this.#elements
55
+ return this.#elements[this.#current]?.elements ?? []
56
+ }
57
+
58
+ /** Every step's elements, flattened — the whole form, for validating all of it at once. */
59
+ get allStepElements() {
60
+ return this.#elements.filter((el) => el.type === 'step').flatMap((step) => step.elements ?? [])
61
+ }
62
+
63
+ advance() {
64
+ if (this.#current >= this.total - 1) return false
65
+ this.#current++
66
+ return true
67
+ }
68
+
69
+ back() {
70
+ if (this.#current === 0) return false
71
+ this.#current--
72
+ return true
73
+ }
74
+
75
+ /** Return to a step already visited. Forward is only reachable through `next()`. */
76
+ goTo(index) {
77
+ if (index >= this.#current) throw new Error('Cannot navigate forward to an unvisited step')
78
+ this.#current = index
79
+ }
80
+ }