@rokkit/forms 1.0.0-next.151 → 1.0.0-next.153
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/dist/src/index.d.ts +1 -0
- package/dist/src/lib/builder.svelte.d.ts +35 -0
- package/package.json +1 -1
- package/src/FormRenderer.svelte +44 -11
- package/src/StepIndicator.svelte +41 -0
- package/src/index.js +1 -0
- package/src/lib/builder.svelte.js +153 -3
package/dist/src/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { default as Input } from "./Input.svelte";
|
|
|
6
6
|
export { default as InputField } from "./InputField.svelte";
|
|
7
7
|
export { default as InfoField } from "./InfoField.svelte";
|
|
8
8
|
export { default as StatusList } from "./StatusList.svelte";
|
|
9
|
+
export { default as StepIndicator } from "./StepIndicator.svelte";
|
|
9
10
|
export * from "./display";
|
|
10
11
|
export * from "./input";
|
|
11
12
|
export { createLookup, createLookupManager, clearLookupCache } from "./lib/lookup.svelte.js";
|
|
@@ -14,6 +14,8 @@ export class FormBuilder {
|
|
|
14
14
|
});
|
|
15
15
|
/** @type {FormElement[]} */
|
|
16
16
|
elements: FormElement[];
|
|
17
|
+
/** True when currentStep can advance to the next step */
|
|
18
|
+
canAdvance: boolean;
|
|
17
19
|
/** Combined schema+layout (scoped elements only) */
|
|
18
20
|
get combined(): any;
|
|
19
21
|
/**
|
|
@@ -194,6 +196,39 @@ export class FormBuilder {
|
|
|
194
196
|
* Reset form data to initial snapshot and clear validation
|
|
195
197
|
*/
|
|
196
198
|
reset(): void;
|
|
199
|
+
/** True when the layout contains step elements */
|
|
200
|
+
get isMultiStep(): any;
|
|
201
|
+
/** Number of step elements in the layout */
|
|
202
|
+
get totalSteps(): any;
|
|
203
|
+
/** Zero-based index of the active step */
|
|
204
|
+
get currentStep(): number;
|
|
205
|
+
/**
|
|
206
|
+
* Validate the current step and advance if valid.
|
|
207
|
+
* @returns {boolean} true if advanced, false if validation failed
|
|
208
|
+
*/
|
|
209
|
+
next(): boolean;
|
|
210
|
+
/**
|
|
211
|
+
* Move to the previous step without validation.
|
|
212
|
+
* @returns {boolean} true if moved, false if already on first step
|
|
213
|
+
*/
|
|
214
|
+
prev(): boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Navigate to a previously visited step (index < currentStep).
|
|
217
|
+
* @param {number} index - Target step index
|
|
218
|
+
*/
|
|
219
|
+
goToStep(index: number): void;
|
|
220
|
+
/**
|
|
221
|
+
* Validate all fields in a given step. Defaults to currentStep.
|
|
222
|
+
* @param {number} [index] - Step index (defaults to currentStep)
|
|
223
|
+
* @returns {boolean} true if no errors
|
|
224
|
+
*/
|
|
225
|
+
isStepValid(index?: number): boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Validate all fields belonging to a step by index.
|
|
228
|
+
* @param {number} index - Step index
|
|
229
|
+
* @returns {boolean} true if no errors
|
|
230
|
+
*/
|
|
231
|
+
validateStep(index: number): boolean;
|
|
197
232
|
#private;
|
|
198
233
|
}
|
|
199
234
|
export type FormElement = {
|
package/package.json
CHANGED
package/src/FormRenderer.svelte
CHANGED
|
@@ -65,11 +65,10 @@
|
|
|
65
65
|
}
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
// Sync prop changes to builder
|
|
68
|
+
// Sync external data prop changes to builder (effect only tracks `data`, not formBuilder.data,
|
|
69
|
+
// to avoid a proxy-vs-raw-value comparison loop)
|
|
69
70
|
$effect(() => {
|
|
70
|
-
|
|
71
|
-
formBuilder.data = data
|
|
72
|
-
}
|
|
71
|
+
formBuilder.data = data
|
|
73
72
|
})
|
|
74
73
|
|
|
75
74
|
$effect(() => {
|
|
@@ -158,13 +157,22 @@
|
|
|
158
157
|
bind:this={formRoot}
|
|
159
158
|
data-form-root
|
|
160
159
|
data-form-submitting={submitting || undefined}
|
|
160
|
+
data-form-step={formBuilder.isMultiStep ? formBuilder.currentStep : undefined}
|
|
161
161
|
class={className}
|
|
162
162
|
onsubmit={handleSubmit}
|
|
163
163
|
{...props}
|
|
164
164
|
>
|
|
165
|
-
{#
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
{#if formBuilder.isMultiStep}
|
|
166
|
+
<div data-form-step-content>
|
|
167
|
+
{#each formBuilder.elements as element, index (index)}
|
|
168
|
+
{@render renderElement(element)}
|
|
169
|
+
{/each}
|
|
170
|
+
</div>
|
|
171
|
+
{:else}
|
|
172
|
+
{#each formBuilder.elements as element, index (index)}
|
|
173
|
+
{@render renderElement(element)}
|
|
174
|
+
{/each}
|
|
175
|
+
{/if}
|
|
168
176
|
{#if actions}
|
|
169
177
|
{@render actions({
|
|
170
178
|
submitting,
|
|
@@ -173,6 +181,17 @@
|
|
|
173
181
|
submit: handleSubmit,
|
|
174
182
|
reset: handleReset
|
|
175
183
|
})}
|
|
184
|
+
{:else if formBuilder.isMultiStep}
|
|
185
|
+
<div data-form-actions>
|
|
186
|
+
{#if formBuilder.currentStep > 0}
|
|
187
|
+
<button type="button" data-form-prev onclick={() => formBuilder.prev()}>Previous</button>
|
|
188
|
+
{/if}
|
|
189
|
+
{#if formBuilder.canAdvance}
|
|
190
|
+
<button type="button" data-form-next onclick={() => formBuilder.next()}>Next</button>
|
|
191
|
+
{:else}
|
|
192
|
+
<button type="submit" data-form-submit disabled={submitting}>Submit</button>
|
|
193
|
+
{/if}
|
|
194
|
+
</div>
|
|
176
195
|
{:else}
|
|
177
196
|
<div data-form-actions>
|
|
178
197
|
<button
|
|
@@ -188,10 +207,24 @@
|
|
|
188
207
|
{/if}
|
|
189
208
|
</form>
|
|
190
209
|
{:else}
|
|
191
|
-
<div
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
{
|
|
210
|
+
<div
|
|
211
|
+
bind:this={formRoot}
|
|
212
|
+
data-form-root
|
|
213
|
+
data-form-step={formBuilder.isMultiStep ? formBuilder.currentStep : undefined}
|
|
214
|
+
class={className}
|
|
215
|
+
{...props}
|
|
216
|
+
>
|
|
217
|
+
{#if formBuilder.isMultiStep}
|
|
218
|
+
<div data-form-step-content>
|
|
219
|
+
{#each formBuilder.elements as element, index (index)}
|
|
220
|
+
{@render renderElement(element)}
|
|
221
|
+
{/each}
|
|
222
|
+
</div>
|
|
223
|
+
{:else}
|
|
224
|
+
{#each formBuilder.elements as element, index (index)}
|
|
225
|
+
{@render renderElement(element)}
|
|
226
|
+
{/each}
|
|
227
|
+
{/if}
|
|
195
228
|
</div>
|
|
196
229
|
{/if}
|
|
197
230
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* StepIndicator — standalone presentational component showing multi-step form progress.
|
|
4
|
+
* Place anywhere on the page; wire to FormBuilder.currentStep and FormBuilder.goToStep.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
steps = [],
|
|
9
|
+
current = 0,
|
|
10
|
+
onclick = undefined
|
|
11
|
+
} = $props()
|
|
12
|
+
|
|
13
|
+
function stepState(index) {
|
|
14
|
+
if (index < current) return 'complete'
|
|
15
|
+
if (index === current) return 'current'
|
|
16
|
+
return 'upcoming'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function handleClick(index) {
|
|
20
|
+
if (index < current) onclick?.(index)
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<ol data-step-indicator>
|
|
25
|
+
{#each steps as label, index (index)}
|
|
26
|
+
{@const state = stepState(index)}
|
|
27
|
+
<li data-step-item data-step-state={state}>
|
|
28
|
+
{#if state === 'complete'}
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
data-step-number
|
|
32
|
+
onclick={() => handleClick(index)}
|
|
33
|
+
onkeydown={(e) => e.key === 'Enter' && handleClick(index)}
|
|
34
|
+
>{index + 1}</button>
|
|
35
|
+
{:else}
|
|
36
|
+
<span data-step-number>{index + 1}</span>
|
|
37
|
+
{/if}
|
|
38
|
+
<span data-step-label>{label}</span>
|
|
39
|
+
</li>
|
|
40
|
+
{/each}
|
|
41
|
+
</ol>
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { default as Input } from './Input.svelte'
|
|
|
14
14
|
export { default as InputField } from './InputField.svelte'
|
|
15
15
|
export { default as InfoField } from './InfoField.svelte'
|
|
16
16
|
export { default as StatusList } from './StatusList.svelte'
|
|
17
|
+
export { default as StepIndicator } from './StepIndicator.svelte'
|
|
17
18
|
|
|
18
19
|
// Display Components
|
|
19
20
|
export * from './display'
|
|
@@ -127,9 +127,15 @@ export class FormBuilder {
|
|
|
127
127
|
/** @type {ReturnType<typeof createLookupManager>|null} */
|
|
128
128
|
#lookupManager = $state(null)
|
|
129
129
|
|
|
130
|
+
/** @type {number} */
|
|
131
|
+
#currentStep = $state(0)
|
|
132
|
+
|
|
130
133
|
/** @type {FormElement[]} */
|
|
131
134
|
elements = $derived(this.#buildElements())
|
|
132
135
|
|
|
136
|
+
/** True when currentStep can advance to the next step */
|
|
137
|
+
canAdvance = $derived(this.#currentStep < this.totalSteps - 1)
|
|
138
|
+
|
|
133
139
|
/** Combined schema+layout (scoped elements only) */
|
|
134
140
|
get combined() {
|
|
135
141
|
const scopedElements = (this.#layout?.elements ?? []).filter((el) => el.scope)
|
|
@@ -459,7 +465,7 @@ export class FormBuilder {
|
|
|
459
465
|
*/
|
|
460
466
|
#buildElements() {
|
|
461
467
|
try {
|
|
462
|
-
const layoutElements = this.#
|
|
468
|
+
const layoutElements = this.#getActiveElements()
|
|
463
469
|
const combinedMap = this.#buildCombinedMap(layoutElements)
|
|
464
470
|
return this.#collectElements(layoutElements, combinedMap)
|
|
465
471
|
} catch (error) {
|
|
@@ -708,6 +714,17 @@ export class FormBuilder {
|
|
|
708
714
|
* @returns {Object} Validation results keyed by field path
|
|
709
715
|
*/
|
|
710
716
|
validate() {
|
|
717
|
+
if (this.isMultiStep) {
|
|
718
|
+
// Flatten all step elements into a synthetic layout for full validation
|
|
719
|
+
const allElements = []
|
|
720
|
+
for (const step of this.#layout?.elements ?? []) {
|
|
721
|
+
if (step.type === 'step') allElements.push(...(step.elements ?? []))
|
|
722
|
+
}
|
|
723
|
+
const flatLayout = { ...this.#layout, elements: allElements }
|
|
724
|
+
const results = validateAllFields(this.#data, this.#schema, flatLayout)
|
|
725
|
+
this.#validation = results
|
|
726
|
+
return results
|
|
727
|
+
}
|
|
711
728
|
const results = validateAllFields(this.#data, this.#schema, this.#layout)
|
|
712
729
|
const visiblePaths = new SvelteSet(
|
|
713
730
|
this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
|
|
@@ -777,8 +794,7 @@ export class FormBuilder {
|
|
|
777
794
|
* @returns {Set<string>}
|
|
778
795
|
*/
|
|
779
796
|
get dirtyFields() {
|
|
780
|
-
|
|
781
|
-
const dirty = new Set()
|
|
797
|
+
const dirty = new SvelteSet()
|
|
782
798
|
this.#collectDirtyFields(this.#data, this.#initialData, '', dirty)
|
|
783
799
|
return dirty
|
|
784
800
|
}
|
|
@@ -884,10 +900,144 @@ export class FormBuilder {
|
|
|
884
900
|
this.#data = deepClone(this.#initialData)
|
|
885
901
|
this.#validation = {}
|
|
886
902
|
}
|
|
903
|
+
|
|
904
|
+
// ── Multi-Step ────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
/** True when the layout contains step elements */
|
|
907
|
+
get isMultiStep() {
|
|
908
|
+
return (this.#layout?.elements ?? []).some((el) => el.type === 'step')
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Number of step elements in the layout */
|
|
912
|
+
get totalSteps() {
|
|
913
|
+
return (this.#layout?.elements ?? []).filter((el) => el.type === 'step').length
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Zero-based index of the active step */
|
|
917
|
+
get currentStep() {
|
|
918
|
+
return this.#currentStep
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Validate the current step and advance if valid.
|
|
923
|
+
* @returns {boolean} true if advanced, false if validation failed
|
|
924
|
+
*/
|
|
925
|
+
next() {
|
|
926
|
+
if (!this.validateStep(this.#currentStep)) return false
|
|
927
|
+
if (this.#currentStep < this.totalSteps - 1) {
|
|
928
|
+
this.#currentStep++
|
|
929
|
+
return true
|
|
930
|
+
}
|
|
931
|
+
return false
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Move to the previous step without validation.
|
|
936
|
+
* @returns {boolean} true if moved, false if already on first step
|
|
937
|
+
*/
|
|
938
|
+
prev() {
|
|
939
|
+
if (this.#currentStep > 0) {
|
|
940
|
+
this.#currentStep--
|
|
941
|
+
return true
|
|
942
|
+
}
|
|
943
|
+
return false
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Navigate to a previously visited step (index < currentStep).
|
|
948
|
+
* @param {number} index - Target step index
|
|
949
|
+
*/
|
|
950
|
+
goToStep(index) {
|
|
951
|
+
if (index >= this.#currentStep) throw new Error('Cannot navigate forward to an unvisited step')
|
|
952
|
+
this.#currentStep = index
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Validate all fields in a given step. Defaults to currentStep.
|
|
957
|
+
* @param {number} [index] - Step index (defaults to currentStep)
|
|
958
|
+
* @returns {boolean} true if no errors
|
|
959
|
+
*/
|
|
960
|
+
isStepValid(index = this.#currentStep) {
|
|
961
|
+
return this.validateStep(index)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Validate all fields belonging to a step by index.
|
|
966
|
+
* @param {number} index - Step index
|
|
967
|
+
* @returns {boolean} true if no errors
|
|
968
|
+
*/
|
|
969
|
+
validateStep(index) {
|
|
970
|
+
const stepEl = (this.#layout?.elements ?? [])[index]
|
|
971
|
+
if (!stepEl || stepEl.type !== 'step') return true
|
|
972
|
+
const stepLayout = { ...this.#layout, elements: stepEl.elements ?? [] }
|
|
973
|
+
const results = validateAllFields(this.#data, this.#schema, stepLayout)
|
|
974
|
+
this.#applyStepValidation(results, stepEl.elements)
|
|
975
|
+
return isAllValid(results)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Apply validation results for a step's fields into the validation state.
|
|
980
|
+
* @private
|
|
981
|
+
*/
|
|
982
|
+
#applyStepValidation(results, elements) {
|
|
983
|
+
const paths = new SvelteSet(this.#collectStepPaths(elements))
|
|
984
|
+
for (const path of paths) {
|
|
985
|
+
this.setFieldValidation(path, results[path] ?? null)
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Collect all scoped field paths from a step's elements recursively.
|
|
991
|
+
* @private
|
|
992
|
+
* @param {Object[]} elements - Step element array
|
|
993
|
+
* @returns {string[]}
|
|
994
|
+
*/
|
|
995
|
+
#collectStepPaths(elements) {
|
|
996
|
+
const paths = []
|
|
997
|
+
for (const el of elements ?? []) {
|
|
998
|
+
if (el.scope) paths.push(el.scope.replace(/^#\//, ''))
|
|
999
|
+
if (el.elements) paths.push(...this.#collectStepPaths(el.elements))
|
|
1000
|
+
}
|
|
1001
|
+
return paths
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Collect all field paths across all steps.
|
|
1006
|
+
* @private
|
|
1007
|
+
* @returns {Set<string>}
|
|
1008
|
+
*/
|
|
1009
|
+
#getAllStepPaths() {
|
|
1010
|
+
const paths = new SvelteSet()
|
|
1011
|
+
for (const step of this.#layout?.elements ?? []) {
|
|
1012
|
+
if (step.type === 'step') {
|
|
1013
|
+
for (const path of this.#collectStepPaths(step.elements)) paths.add(path)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return paths
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Return the layout elements for the active step (or all elements for flat layouts).
|
|
1021
|
+
* @private
|
|
1022
|
+
* @returns {Object[]}
|
|
1023
|
+
*/
|
|
1024
|
+
#getActiveElements() {
|
|
1025
|
+
const elements = this.#layout?.elements ?? []
|
|
1026
|
+
if (!elements.some((el) => el.type === 'step')) return elements
|
|
1027
|
+
return elements[this.#currentStep]?.elements ?? []
|
|
1028
|
+
}
|
|
887
1029
|
}
|
|
888
1030
|
|
|
889
1031
|
// ── Module-level helpers (no `this`) ──────────────────────────────────────────
|
|
890
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Returns true when no result in the map has state === 'error'
|
|
1035
|
+
* @private
|
|
1036
|
+
*/
|
|
1037
|
+
function isAllValid(results) {
|
|
1038
|
+
return Object.values(results).every((msg) => msg?.state !== 'error')
|
|
1039
|
+
}
|
|
1040
|
+
|
|
891
1041
|
/**
|
|
892
1042
|
* Return the display label for a layout element, falling back to fieldPath
|
|
893
1043
|
* @private
|