@rokkit/forms 1.0.0-next.150 → 1.0.0-next.152

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/forms",
3
- "version": "1.0.0-next.150",
3
+ "version": "1.0.0-next.152",
4
4
  "description": "Form building components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- if (formBuilder.data !== data) {
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
- {#each formBuilder.elements as element, index (index)}
166
- {@render renderElement(element)}
167
- {/each}
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 bind:this={formRoot} data-form-root class={className} {...props}>
192
- {#each formBuilder.elements as element, index (index)}
193
- {@render renderElement(element)}
194
- {/each}
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.#layout?.elements ?? []
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
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
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