@svgrid/enterprise 2.6.0 → 2.6.1

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.
@@ -328,7 +328,10 @@
328
328
  .sx-combinator { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.7; }
329
329
  .sx-rows { display: flex; flex-direction: column; gap: 7px; }
330
330
  .sx-row {
331
- display: grid; grid-template-columns: minmax(90px, 1fr) minmax(90px, 1fr) 2fr auto; gap: 7px; align-items: center;
331
+ /* minmax(0, 2fr), not 2fr: a grid track sizes to its content by default, so
332
+ the value input refused to shrink and pushed the row out of any narrow
333
+ panel (the form builder's rules pane, a docked alerts panel). */
334
+ display: grid; grid-template-columns: minmax(90px, 1fr) minmax(90px, 1fr) minmax(0, 2fr) auto; gap: 7px; align-items: center;
332
335
  border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 9px; padding: 8px;
333
336
  }
334
337
  .sx-noval { text-align: center; font-size: 13px; opacity: 0.5; }
@@ -89,6 +89,8 @@
89
89
  * Fields not in any section render in a trailing default group. When set,
90
90
  * `formFields` ordering is per-section. */
91
91
  sections?: FormSection[]
92
+ /** Ask one section at a time (Back / Next). Defaults to the schema's `form.steps`. */
93
+ steps?: boolean
92
94
  /** Dialog width for the modal / drawer presentations. Default 'md'. */
93
95
  formSize?: 'sm' | 'md' | 'lg'
94
96
  onSubmit: (payload: SubmitPayload) => void | Promise<void>
@@ -109,6 +111,7 @@
109
111
  columns,
110
112
  formFields,
111
113
  sections,
114
+ steps,
112
115
  formSize = 'md',
113
116
  onSubmit,
114
117
  onCancel,
@@ -144,22 +147,87 @@
144
147
 
145
148
  // Grouped layout: each section's resolvable fields, plus a trailing group for
146
149
  // anything not assigned to a section (so no field is ever silently dropped).
147
- const fieldGroups = $derived.by((): Array<{ title?: string; description?: string; columns?: 1 | 2 | 3; fields: FormFieldDescriptor[] }> => {
148
- if (!layoutSections || !layoutSections.length) return [{ fields }]
150
+ // Read straight off the layout, not off `fieldGroups` - the grouping needs to
151
+ // know whether we are stepping, so it cannot be what decides it.
152
+ const wantSteps = $derived(!!(steps ?? schema.form?.steps) && !!layoutSections?.length)
153
+ const fieldGroups = $derived.by((): Array<{ title?: string; description?: string; columns?: 1 | 2 | 3; collapsible?: boolean; key: string; fields: FormFieldDescriptor[] }> => {
154
+ if (!layoutSections || !layoutSections.length) return [{ key: '', fields }]
149
155
  const assigned = new Set<string>()
150
- const groups = layoutSections.map((s) => {
156
+ const groups = layoutSections.map((s, i) => {
151
157
  const gf = s.fields.map((name) => fields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[]
152
158
  for (const f of gf) assigned.add(f.field)
153
159
  // A section can be conditional in its own right, the same way a field is.
154
160
  const shown = s.visibleWhen ? sectionVisible(s.visibleWhen, values) : true
155
- return { title: s.title, description: s.description, columns: s.columns, fields: shown ? gf : [] }
161
+ return { title: s.title, description: s.description, columns: s.columns, collapsible: s.collapsible, key: `${i}`, fields: shown ? gf : [] }
156
162
  })
157
163
  const rest = fields.filter((f) => !assigned.has(f.field))
158
164
  // A section whose fields are all hidden by a condition disappears with them,
159
165
  // rather than leaving a heading over nothing.
160
166
  const shown = groups.filter((g) => g.fields.length)
161
- return rest.length ? [...shown, { fields: rest }] : shown
167
+ if (!rest.length) return shown
168
+ // Stepping, the leftovers join the last step rather than becoming a step of
169
+ // their own: an untitled "Step 4" holding whatever nobody placed is a
170
+ // mystery, and every step of a wizard should be deliberate.
171
+ if (wantSteps && shown.length) {
172
+ const last = shown[shown.length - 1]!
173
+ return [...shown.slice(0, -1), { ...last, fields: [...last.fields, ...rest] }]
174
+ }
175
+ return [...shown, { key: 'rest', fields: rest }]
176
+ })
177
+
178
+ // Which collapsible sections the user has folded away. Seeded from the
179
+ // layout's `collapsed`, then owned by the user for the life of the form.
180
+ let folded = $state<Record<string, boolean>>({})
181
+ $effect(() => {
182
+ const seed: Record<string, boolean> = {}
183
+ ;(layoutSections ?? []).forEach((s, i) => { if (s.collapsible && s.collapsed) seed[`${i}`] = true })
184
+ folded = seed
162
185
  })
186
+ /**
187
+ * A folded section is a display state, not a condition - its fields are still
188
+ * validated. So a section holding an error is forced open: a failed submit
189
+ * must never point at something the user cannot see.
190
+ */
191
+ const groupHasError = (g: { fields: FormFieldDescriptor[] }) => g.fields.some((f) => shownErrors[f.field])
192
+ // Never folded while stepping: a step is already one group at a time, and a
193
+ // step you have to unfold before you can fill it in is just an extra click.
194
+ const isFolded = (g: { key: string; collapsible?: boolean; fields: FormFieldDescriptor[] }) =>
195
+ !stepped && !!g.collapsible && !!folded[g.key] && !groupHasError(g)
196
+
197
+ // --- Steps -----------------------------------------------------------------
198
+ // One section at a time. The groups are already the steps (a section hidden by
199
+ // its condition has been filtered out upstream, so the count follows the
200
+ // answers), which is why there is no second list to keep in sync.
201
+ const stepped = $derived(wantSteps && fieldGroups.length > 1)
202
+ let step = $state(0)
203
+ // Clamp rather than reset: answering something that hides a later section must
204
+ // not throw the user back to the beginning.
205
+ const stepIndex = $derived(stepped ? Math.min(step, fieldGroups.length - 1) : 0)
206
+ const visibleGroups = $derived(stepped ? [fieldGroups[stepIndex]!] : fieldGroups)
207
+ const isLastStep = $derived(stepIndex === fieldGroups.length - 1)
208
+
209
+ /**
210
+ * Move to the next step, but only once this one is clean. Validating just the
211
+ * step you are on is the point of a wizard: errors surface where they were
212
+ * made instead of arriving in a heap at the end.
213
+ */
214
+ async function nextStep() {
215
+ const group = fieldGroups[stepIndex]
216
+ if (!group) return
217
+ let bad = false
218
+ for (const f of group.fields) {
219
+ touched[f.field] = true
220
+ const message = await validateOne(schema, f.field, values)
221
+ if (message) { errors[f.field] = message; bad = true }
222
+ else delete errors[f.field]
223
+ }
224
+ if (bad) {
225
+ const first = group.fields.find((f) => errors[f.field])
226
+ if (first) focusField(first.field)
227
+ return
228
+ }
229
+ step = stepIndex + 1
230
+ }
163
231
 
164
232
  // Options for the custom dropdown: prepend a blank "clear" option for
165
233
  // non-required fields (parity with the native select's empty option).
@@ -184,6 +252,7 @@
184
252
  errors = {}
185
253
  touched = {}
186
254
  submitAttempted = false
255
+ step = 0
187
256
  submitError = null
188
257
  confirmingDiscard = false
189
258
  })
@@ -243,13 +312,22 @@
243
312
 
244
313
  async function handleSubmit(event: SubmitEvent) {
245
314
  event.preventDefault()
315
+ // Enter in a text field still submits a form even with no submit button on
316
+ // screen. Mid-wizard that would save a half-filled record, so it advances
317
+ // instead - the same thing Next does.
318
+ if (stepped && !isLastStep) { void nextStep(); return }
246
319
  submitError = null
247
320
  submitAttempted = true
248
321
  const found = await validateAll(schema, values)
249
322
  errors = found
250
323
  if (Object.keys(found).length > 0) {
251
- // Put the user on the first thing they need to fix.
324
+ // Put the user on the first thing they need to fix - and, when stepping,
325
+ // on the step it is actually on, or the focus would land off-screen.
252
326
  const first = fields.find((f) => found[f.field])
327
+ if (stepped && first) {
328
+ const at = fieldGroups.findIndex((g) => g.fields.some((f) => found[f.field]))
329
+ if (at >= 0) step = at
330
+ }
253
331
  if (first) focusField(first.field)
254
332
  return
255
333
  }
@@ -482,12 +560,38 @@
482
560
  </div>
483
561
  {/if}
484
562
 
563
+ {#if stepped}
564
+ <!-- Where you are, and how much is left. Named steps beat "3 of 7": the
565
+ titles are already written, so use them. -->
566
+ <ol class="sv-ep__steps" aria-label="Form steps">
567
+ {#each fieldGroups as g, gi (gi)}
568
+ <li class="sv-ep__step" class:is-current={gi === stepIndex} class:is-done={gi < stepIndex} aria-current={gi === stepIndex ? 'step' : undefined}>
569
+ <span class="sv-ep__step-dot" aria-hidden="true">{gi < stepIndex ? '✓' : gi + 1}</span>
570
+ <span class="sv-ep__step-label">{g.title ?? `Step ${gi + 1}`}</span>
571
+ </li>
572
+ {/each}
573
+ </ol>
574
+ {/if}
575
+
485
576
  {#if fieldGroups.length > 1 || fieldGroups[0]?.title}
486
- {#each fieldGroups as g, gi (gi)}
577
+ {#each visibleGroups as g, gi (gi)}
578
+ {@const shut = isFolded(g)}
487
579
  <div class="sv-ep__section">
488
- {#if g.title}<h4 class="sv-ep__section-title">{g.title}</h4>{/if}
489
- {#if g.description}<p class="sv-ep__section-desc">{g.description}</p>{/if}
490
- <div class="sv-ep__body" style="--sv-ep-cols: {g.columns ?? layoutColumns}">
580
+ {#if g.collapsible && g.title}
581
+ <!-- The heading becomes the control, so the whole row is the target
582
+ rather than a small chevron beside it. -->
583
+ <h4 class="sv-ep__section-title">
584
+ <button type="button" class="sv-ep__section-toggle" aria-expanded={!shut} aria-controls={`sv-eg-${gi}`} onclick={() => (folded[g.key] = !folded[g.key])}>
585
+ <span class="sv-ep__section-caret" class:is-shut={shut} aria-hidden="true"></span>
586
+ {g.title}
587
+ {#if shut}<span class="sv-ep__section-count">{g.fields.length}</span>{/if}
588
+ </button>
589
+ </h4>
590
+ {:else if g.title}
591
+ <h4 class="sv-ep__section-title">{g.title}</h4>
592
+ {/if}
593
+ {#if g.description && !shut}<p class="sv-ep__section-desc">{g.description}</p>{/if}
594
+ <div class="sv-ep__body" id={`sv-eg-${gi}`} hidden={shut} style="--sv-ep-cols: {g.columns ?? layoutColumns}">
491
595
  {#each g.fields as f (f.field)}{@render fieldRow(f)}{/each}
492
596
  </div>
493
597
  </div>
@@ -509,9 +613,18 @@
509
613
  {#if onCancel}
510
614
  <button type="button" class="sv-ep__btn" onclick={close} disabled={submitting}>Cancel</button>
511
615
  {/if}
512
- <button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
513
- {submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
514
- </button>
616
+ {#if stepped && stepIndex > 0}
617
+ <button type="button" class="sv-ep__btn" onclick={() => (step = stepIndex - 1)} disabled={submitting}>Back</button>
618
+ {/if}
619
+ {#if stepped && !isLastStep}
620
+ <!-- Not a submit button: Next validates this step only, and a stray
621
+ Enter in a text field must not save a half-filled record. -->
622
+ <button type="button" class="sv-ep__btn sv-ep__btn--primary" onclick={nextStep} disabled={submitting}>Next</button>
623
+ {:else}
624
+ <button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
625
+ {submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
626
+ </button>
627
+ {/if}
515
628
  {/if}
516
629
  </footer>
517
630
  </form>
@@ -571,28 +684,28 @@
571
684
  <!-- Boolean fields render as the suite's switch (nicer than a raw checkbox). -->
572
685
  <SvSwitchButton id={`sv-ef-${f.field}`} ariaLabel={f.label} checked={!!values[f.field]} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
573
686
  {:else if f.editorType === 'number'}
574
- <SvNumberInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toNumberValue(values[f.field])} min={f.min} max={f.max} step={f.step} precision={f.precision} prefix={f.prefix} suffix={f.suffix} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = fromNumberValue(v))} />
687
+ <SvNumberInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toNumberValue(values[f.field])} min={f.min} max={f.max} step={f.step} precision={f.precision} prefix={f.prefix} suffix={f.suffix} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = fromNumberValue(v))} />
575
688
  {:else if f.editorType === 'color'}
576
- <SvColorInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!err} onChange={(v) => (values[f.field] = v)} />
689
+ <SvColorInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!err} onChange={(v) => (values[f.field] = v)} />
577
690
  {:else if f.editorType === 'password'}
578
- <SvPasswordInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
691
+ <SvPasswordInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
579
692
  {:else if f.editorType === 'rating' || f.editorType === 'slider'}
580
693
  {@const rmin = f.min ?? 0}
581
694
  {@const rmax = f.max ?? (f.editorType === 'rating' ? 5 : 100)}
582
- <SvSlider id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toSliderValue(values[f.field], rmin)} min={rmin} max={rmax} step={1} ticks={f.editorType === 'rating' ? rmax - rmin + 1 : undefined} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
695
+ <SvSlider block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toSliderValue(values[f.field], rmin)} min={rmin} max={rmax} step={1} ticks={f.editorType === 'rating' ? rmax - rmin + 1 : undefined} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
583
696
  {:else if f.editorType === 'phone'}
584
- <SvPhoneInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
697
+ <SvPhoneInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
585
698
  {:else if f.editorType === 'country'}
586
- <SvCountryInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
699
+ <SvCountryInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
587
700
  {:else if f.editorType === 'mask'}
588
- <SvMaskedInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(masked) => (values[f.field] = masked)} />
701
+ <SvMaskedInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(masked) => (values[f.field] = masked)} />
589
702
  {:else if f.editorType === 'date'}
590
- <SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd'} onChange={(d) => (values[f.field] = toDateString(d))} />
703
+ <SvDateTimePicker block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd'} onChange={(d) => (values[f.field] = toDateString(d))} />
591
704
  {:else if f.editorType === 'datetime'}
592
- <SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd hh:mm'} onChange={(d) => (values[f.field] = toDateTimeString(d))} />
705
+ <SvDateTimePicker block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd hh:mm'} onChange={(d) => (values[f.field] = toDateTimeString(d))} />
593
706
  {:else if f.editorType === 'chips'}
594
707
  <!-- Multi-value entry: stores a string[]. (Previously a chips field fell back to a single-select.) -->
595
- <SvTagsInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder ?? 'Add…'} onChange={(tags) => (values[f.field] = tags)} />
708
+ <SvTagsInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder ?? 'Add…'} onChange={(tags) => (values[f.field] = tags)} />
596
709
  {:else if kind === 'select'}
597
710
  {#if f.readonly}
598
711
  <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" value={values[f.field] ?? ''} disabled />
@@ -674,7 +787,9 @@
674
787
  {/if}
675
788
  </div>
676
789
  {:else}
677
- <div class="sv-ep sv-ep--inline" role="dialog" aria-label={heading}>
790
+ <!-- The size class matters for inline too now: it fills its container by
791
+ default, and `formSize` is what narrows it. -->
792
+ <div class="sv-ep sv-ep--inline sv-ep--sz-{formSize}" role="dialog" aria-label={heading}>
678
793
  {@render panelInner()}
679
794
  </div>
680
795
  {/if}
@@ -742,12 +857,16 @@
742
857
  display: flex;
743
858
  flex-direction: column;
744
859
  }
860
+ /* Inline fills whatever it is placed in - a form block on a page is as wide as
861
+ the block, and a fixed cap here made a wide block look broken. Narrow it
862
+ with `formSize` when a full-width form is too much to read. */
745
863
  .sv-ep--inline {
746
864
  width: 100%;
747
- max-width: 460px;
748
865
  border: 1px solid var(--ep-border);
749
866
  border-radius: var(--ep-radius);
750
867
  }
868
+ .sv-ep--sz-sm.sv-ep--inline { max-width: 460px; }
869
+ .sv-ep--sz-lg.sv-ep--inline { max-width: 900px; }
751
870
 
752
871
  .sv-ep__form {
753
872
  display: flex;
@@ -849,8 +968,26 @@
849
968
  /* Grouped layout: one scroll region holds the titled fieldsets. */
850
969
  .sv-ep__section { display: flex; flex-direction: column; }
851
970
  .sv-ep__section + .sv-ep__section { border-top: 1px solid var(--sg-border, #e6e8ec); }
852
- .sv-ep__section-title { margin: 0; padding: 14px 18px 0; font-size: 12.5px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
853
- .sv-ep__section-desc { margin: 4px 0 0; padding: 0 18px; font-size: 12px; color: var(--sg-muted, #64748b); }
971
+ /* A section heading is a heading, not a label: it reads at the same weight as
972
+ the dialog title, one size down. An all-caps micro-label got lost among the
973
+ field labels, which are themselves small and muted. */
974
+ .sv-ep__section-title { margin: 0; padding: 16px 18px 0; font-size: 13.5px; font-weight: 650; color: var(--ep-fg); }
975
+ .sv-ep__section-desc { margin: 3px 0 0; padding: 0 18px; font-size: 12px; line-height: 1.45; color: var(--sg-muted, #64748b); }
976
+ /* The step rail. Wraps rather than scrolls: a wizard with six steps in a
977
+ narrow drawer should read as two rows, not run off the edge. */
978
+ .sv-ep__steps { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; padding: 14px 18px 0; list-style: none; }
979
+ .sv-ep__step { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--sg-muted, #64748b); }
980
+ .sv-ep__step-dot { display: inline-flex; align-items: center; justify-content: center; width: 19px; height: 19px; border-radius: 50%; font-size: 10.5px; font-weight: 700; background: var(--sg-muted-bg, #eef2f7); color: var(--sg-muted, #64748b); }
981
+ .sv-ep__step.is-current { color: var(--ep-fg); font-weight: 600; }
982
+ .sv-ep__step.is-current .sv-ep__step-dot { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }
983
+ .sv-ep__step.is-done .sv-ep__step-dot { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 18%, transparent); color: var(--sg-accent, #4f46e5); }
984
+ .sv-ep__section-toggle { display: flex; align-items: center; gap: 7px; width: 100%; padding: 0; font: inherit; text-align: left; border: 0; background: none; color: inherit; cursor: pointer; }
985
+ .sv-ep__section-caret { width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid currentColor; transition: transform 120ms ease; }
986
+ .sv-ep__section-caret.is-shut { transform: rotate(-90deg); }
987
+ /* How many fields are hidden in there - a folded group should not look empty. */
988
+ .sv-ep__section-count { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; padding: 0 5px; border-radius: 9px; font-size: 10px; font-weight: 600; background: var(--sg-muted-bg, #eef2f7); color: var(--sg-muted, #64748b); }
989
+ /* The heading owns the gap above its fields, so the group reads as one thing. */
990
+ .sv-ep__section-title + .sv-ep__body, .sv-ep__section-desc + .sv-ep__body { padding-top: 10px; }
854
991
  .sv-ep__discard { margin-right: auto; font-size: 12.5px; font-weight: 600; color: var(--ep-danger); }
855
992
  .sv-ep__btn--danger { border-color: var(--ep-danger); background: var(--ep-danger); color: #fff; }
856
993
  .sv-ep__section .sv-ep__body { flex: 0 0 auto; overflow: visible; }
@@ -866,11 +1003,19 @@
866
1003
  .sv-ep-field label {
867
1004
  font-size: 12px;
868
1005
  font-weight: 550;
869
- color: var(--ep-muted);
1006
+ /* The label names the thing you are about to type in, so it reads at full
1007
+ strength; muted text is for the hint underneath it. */
1008
+ color: var(--ep-fg);
1009
+ }
1010
+ .sv-ep-field--error label {
1011
+ color: var(--ep-danger);
870
1012
  }
871
1013
  .sv-ep-field__req {
872
1014
  color: var(--ep-danger);
873
1015
  }
1016
+ /* Controls fill their cell via the suite's own `block` prop - see the markup.
1017
+ The boolean switch deliberately does not: a full-width toggle is not what
1018
+ anybody means by a checkbox. */
874
1019
  .sv-ep-field input,
875
1020
  .sv-ep-field textarea {
876
1021
  box-sizing: border-box;
@@ -40,6 +40,8 @@ declare function $$render<TData extends EditRow>(): {
40
40
  * Fields not in any section render in a trailing default group. When set,
41
41
  * `formFields` ordering is per-section. */
42
42
  sections?: FormSection[];
43
+ /** Ask one section at a time (Back / Next). Defaults to the schema's `form.steps`. */
44
+ steps?: boolean;
43
45
  /** Dialog width for the modal / drawer presentations. Default 'md'. */
44
46
  formSize?: "sm" | "md" | "lg";
45
47
  onSubmit: (payload: {