@asteby/metacore-runtime-react 28.4.1 → 28.6.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.
@@ -54,6 +54,8 @@ import { DynamicRelations } from '../dynamic-relations'
54
54
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
55
55
  import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-form-schema'
56
56
  import type { VisibleWhen } from '../types'
57
+ import { groupFieldsBySection, type FormLayout } from '../form-layout'
58
+ import { FieldSection, WizardProgress } from '../form-layout-ui'
57
59
  import { FieldCell } from '../field-grid'
58
60
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
59
61
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
@@ -158,6 +160,12 @@ export interface FieldDef {
158
160
  visible_when?: VisibleWhen
159
161
  /** camelCase alias for `visible_when`. */
160
162
  visibleWhen?: VisibleWhen
163
+ /**
164
+ * Form-layout membership: the key of the `form_layout` section this field
165
+ * belongs to (kernel PR #230). Absent → the default group. See
166
+ * `groupFieldsBySection`.
167
+ */
168
+ section?: string
161
169
  }
162
170
 
163
171
  // Permissive shape: the wire payload may omit some fields (e.g. `title` is
@@ -175,6 +183,15 @@ interface ModalMetadata {
175
183
  createTitle?: string
176
184
  editTitle?: string
177
185
  fields?: FieldDef[]
186
+ /**
187
+ * Declarative form layout (kernel PR #230): groups the fields into named
188
+ * sections rendered stacked (`mode:"sections"`) or as a wizard
189
+ * (`mode:"steps"`). Absent → the legacy flat two-column grid. Tolerates the
190
+ * camelCase alias an app might author.
191
+ */
192
+ form_layout?: FormLayout
193
+ /** camelCase alias for `form_layout`. */
194
+ formLayout?: FormLayout
178
195
  /**
179
196
  * Backend-localized CRUD success messages (modal metadata). Preferred over
180
197
  * the raw response message which is not localized.
@@ -490,6 +507,29 @@ export function filterVisibleFields(
490
507
  })
491
508
  }
492
509
 
510
+ // stripHiddenFieldValues drops from a flat form-values object the keys of any
511
+ // declared field currently hidden by its `visible_when` predicate, so the
512
+ // submit never POSTs a value the form isn't showing (e.g. a DiscountRule with
513
+ // rule_scope=category must not send the product_id / customer_id it hides).
514
+ // This mirrors dynamic-form.tsx, which builds its Zod schema only over the
515
+ // visible fields — hidden fields are dropped from BOTH the render/required-gate
516
+ // AND the submitted values. Keys with no matching declared field, or whose
517
+ // field carries no `visible_when`, always pass through (retrocompat).
518
+ export function stripHiddenFieldValues(
519
+ values: Record<string, any>,
520
+ fields: FieldDef[] | undefined,
521
+ mode: 'view' | 'edit' | 'create',
522
+ ): Record<string, any> {
523
+ const visibleKeys = new Set(filterVisibleFields(fields, mode, values).map(f => f.key))
524
+ const out: Record<string, any> = {}
525
+ for (const [key, value] of Object.entries(values)) {
526
+ const field = (fields ?? []).find(f => f.key === key)
527
+ if (field && !visibleKeys.has(key) && getVisibleWhen(field)) continue
528
+ out[key] = value
529
+ }
530
+ return out
531
+ }
532
+
493
533
  export function DynamicRecordDialog({
494
534
  open,
495
535
  onOpenChange,
@@ -526,6 +566,8 @@ export function DynamicRecordDialog({
526
566
  const [loading, setLoading] = useState(false)
527
567
  const [saving, setSaving] = useState(false)
528
568
  const [deleting, setDeleting] = useState(false)
569
+ // Wizard step cursor (form_layout mode:"steps" only).
570
+ const [stepIndex, setStepIndex] = useState(0)
529
571
 
530
572
  const isCreate = mode === 'create'
531
573
  const isView = mode === 'view'
@@ -537,8 +579,10 @@ export function DynamicRecordDialog({
537
579
  if (!open) return
538
580
  if (!isCreate && !recordId) return
539
581
 
540
- // Fresh open → drop any validation errors from a prior submit.
582
+ // Fresh open → drop any validation errors from a prior submit and reset
583
+ // the wizard to its first step.
541
584
  setFieldErrors({})
585
+ setStepIndex(0)
542
586
 
543
587
  let cancelled = false
544
588
 
@@ -752,9 +796,16 @@ export function DynamicRecordDialog({
752
796
  // Required check passed → clear any prior validation errors.
753
797
  setFieldErrors({})
754
798
 
799
+ // Fields hidden by their `visible_when` predicate must not be POSTed:
800
+ // the render, the required-gate and the payload all drive off the same
801
+ // filter, so a DiscountRule with scope=category never submits the
802
+ // product_id / customer_id it isn't showing. Mirrors dynamic-form.tsx,
803
+ // which builds its Zod only over visibleFields.
804
+ const submittedValues = stripHiddenFieldValues(formValues, modalMeta.fields, mode)
805
+
755
806
  // Empty reference pickers → null (not "" / nil-UUID) so nullable FK
756
807
  // columns accept them instead of raising a 23503 FK violation.
757
- const payload = normalizeRefFieldsForSubmit(formValues, modalMeta.fields)
808
+ const payload = normalizeRefFieldsForSubmit(submittedValues, modalMeta.fields)
758
809
 
759
810
  setSaving(true)
760
811
  try {
@@ -829,6 +880,65 @@ export function DynamicRecordDialog({
829
880
 
830
881
  const visibleFields = filterVisibleFields(modalMeta?.fields, mode, formValues)
831
882
 
883
+ // Declarative form layout: group the (already visibility-filtered) fields by
884
+ // their section. Empty sections drop out for free. Steps mode only drives a
885
+ // wizard in editable modes — view mode always stacks the sections.
886
+ const formLayout = modalMeta?.form_layout ?? modalMeta?.formLayout
887
+ const groups = groupFieldsBySection(visibleFields, formLayout, formValues)
888
+ const isSteps = isEditable && formLayout?.mode === 'steps' && groups.length > 1
889
+ const clampedStep = Math.min(stepIndex, Math.max(groups.length - 1, 0))
890
+ const isLastStep = clampedStep === groups.length - 1
891
+
892
+ // Renders a list of fields into the shared two-column grid (each FieldCell
893
+ // gives min-w-0 so long values can't blow the columns past the dialog).
894
+ const renderFields = (groupFields: FieldDef[]) =>
895
+ groupFields.map(field => {
896
+ const isFullWidth =
897
+ field.type === 'textarea' ||
898
+ field.widget === 'textarea' ||
899
+ field.widget === 'richtext'
900
+ return (
901
+ <FieldCell key={field.key} fullWidth={isFullWidth}>
902
+ <FieldRow
903
+ field={field}
904
+ record={record}
905
+ value={formValues[field.key] ?? ''}
906
+ mode={mode}
907
+ error={fieldErrors[field.key]}
908
+ onChange={val => {
909
+ setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
910
+ setFieldErrors(prev => {
911
+ if (!prev[field.key]) return prev
912
+ const next = { ...prev }
913
+ delete next[field.key]
914
+ return next
915
+ })
916
+ }}
917
+ />
918
+ </FieldCell>
919
+ )
920
+ })
921
+
922
+ // Wizard "Siguiente": gate only the CURRENT step's required (visible) fields,
923
+ // then advance. Mirrors handleSubmit's required check but scoped to the step.
924
+ const goNextStep = () => {
925
+ const step = groups[clampedStep]
926
+ const missing: Record<string, string> = {}
927
+ for (const field of step?.fields ?? []) {
928
+ if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
929
+ missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
930
+ }
931
+ }
932
+ if (Object.keys(missing).length) {
933
+ setFieldErrors(missing)
934
+ toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
935
+ return
936
+ }
937
+ setFieldErrors({})
938
+ setStepIndex(Math.min(clampedStep + 1, groups.length - 1))
939
+ }
940
+ const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0))
941
+
832
942
  return (
833
943
  <Dialog open={open} onOpenChange={onOpenChange}>
834
944
  <DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
@@ -845,54 +955,42 @@ export function DynamicRecordDialog({
845
955
  <ImageUrlContext.Provider value={getImageUrl}>
846
956
  <TimeZoneContext.Provider value={timeZone}>
847
957
  <CurrencyContext.Provider value={currency}>
848
- {/* The grid IS the form element (the footer submit
849
- button targets it by id). FieldCell gives each
958
+ {/* The form element groups its fields by the declared
959
+ form_layout (sections stacked, or the current
960
+ wizard step). Without a layout this is a single
961
+ default group rendered with no chrome — the legacy
962
+ two-column grid, unchanged. FieldCell gives each
850
963
  cell `min-w-0` so a long select/input value can't
851
964
  blow the two columns past the dialog width. */}
852
965
  <form
853
966
  id="dynamic-record-form"
854
967
  onSubmit={handleSubmit}
855
- className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
968
+ className="grid gap-y-4"
856
969
  >
857
- {visibleFields.map(field => {
858
- const isFullWidth =
859
- field.type === 'textarea' ||
860
- field.widget === 'textarea' ||
861
- field.widget === 'richtext'
862
- return (
863
- <FieldCell key={field.key} fullWidth={isFullWidth}>
864
- <FieldRow
865
- field={field}
866
- record={record}
867
- value={formValues[field.key] ?? ''}
868
- mode={mode}
869
- error={fieldErrors[field.key]}
870
- onChange={val => {
871
- setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
872
- // Clear this field's error as soon as the user edits it.
873
- setFieldErrors(prev => {
874
- if (!prev[field.key]) return prev
875
- const next = { ...prev }
876
- delete next[field.key]
877
- return next
878
- })
879
- }}
880
- />
881
- </FieldCell>
882
- )
883
- })}
970
+ {isSteps && (
971
+ <WizardProgress groups={groups} stepIndex={clampedStep} />
972
+ )}
973
+ {(isSteps ? [groups[clampedStep]] : groups).map(group => (
974
+ <FieldSection key={group.key} group={group}>
975
+ <div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
976
+ {renderFields(group.fields)}
977
+ </div>
978
+ </FieldSection>
979
+ ))}
884
980
 
885
981
  {record?.external_url && (
886
- <div className="sm:col-span-2 min-w-0">
887
- <a
888
- href={record.external_url}
889
- target="_blank"
890
- rel="noreferrer"
891
- className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
892
- >
893
- <ExternalLink className="h-3.5 w-3.5" />
894
- Ver en {record.external_provider ?? 'proveedor externo'}
895
- </a>
982
+ <div className="grid grid-cols-1 sm:grid-cols-2">
983
+ <div className="sm:col-span-2 min-w-0">
984
+ <a
985
+ href={record.external_url}
986
+ target="_blank"
987
+ rel="noreferrer"
988
+ className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
989
+ >
990
+ <ExternalLink className="h-3.5 w-3.5" />
991
+ Ver en {record.external_provider ?? 'proveedor externo'}
992
+ </a>
993
+ </div>
896
994
  </div>
897
995
  )}
898
996
  </form>
@@ -933,9 +1031,17 @@ export function DynamicRecordDialog({
933
1031
  </Button>
934
1032
  ) : <span />}
935
1033
  <div className="flex items-center gap-2">
936
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
937
- {config.cancelLabel}
938
- </Button>
1034
+ {/* Wizard "Anterior" replaces Cancel from the second step
1035
+ on; the first step still shows Cancel. */}
1036
+ {isSteps && clampedStep > 0 ? (
1037
+ <Button variant="outline" onClick={goBackStep} disabled={saving || deleting}>
1038
+ Anterior
1039
+ </Button>
1040
+ ) : (
1041
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
1042
+ {config.cancelLabel}
1043
+ </Button>
1044
+ )}
939
1045
  {isView && onDelete && (
940
1046
  <Button
941
1047
  variant="destructive"
@@ -951,7 +1057,15 @@ export function DynamicRecordDialog({
951
1057
  Editar
952
1058
  </Button>
953
1059
  )}
954
- {isEditable && (
1060
+ {/* Non-final wizard step → "Siguiente" validates the step
1061
+ and advances instead of submitting. The last step (and
1062
+ every non-wizard form) keeps the real submit button. */}
1063
+ {isEditable && isSteps && !isLastStep && (
1064
+ <Button type="button" onClick={goNextStep} disabled={saving || loading}>
1065
+ Siguiente
1066
+ </Button>
1067
+ )}
1068
+ {isEditable && (!isSteps || isLastStep) && (
955
1069
  <Button
956
1070
  type="submit"
957
1071
  form="dynamic-record-form"
@@ -2,6 +2,8 @@
2
2
  // pattern + ActionFieldDef renderer so callers can reuse the form layout
3
3
  // outside the full record-edit modal.
4
4
  import { useEffect, useMemo, useState } from 'react'
5
+ import { groupFieldsBySection, type FormLayout } from './form-layout'
6
+ import { FieldSection, WizardProgress } from './form-layout-ui'
5
7
  import {
6
8
  Input,
7
9
  Textarea,
@@ -47,6 +49,14 @@ export interface DynamicFormProps {
47
49
  submitLabel?: string
48
50
  cancelLabel?: string
49
51
  disabled?: boolean
52
+ /**
53
+ * Declarative form layout served on the model metadata (kernel PR #230).
54
+ * When present the visible fields are grouped by their `section` respecting
55
+ * `sections` order: `mode:"sections"` stacks (optionally collapsible)
56
+ * sections; `mode:"steps"` renders a Anterior/Siguiente wizard. Absent → the
57
+ * legacy flat list (unchanged).
58
+ */
59
+ formLayout?: FormLayout
50
60
  }
51
61
 
52
62
  export function DynamicForm({
@@ -57,6 +67,7 @@ export function DynamicForm({
57
67
  submitLabel = 'Guardar',
58
68
  cancelLabel = 'Cancelar',
59
69
  disabled = false,
70
+ formLayout,
60
71
  }: DynamicFormProps) {
61
72
  const [values, setValues] = useState<Record<string, any>>({})
62
73
  const [errors, setErrors] = useState<Record<string, string>>({})
@@ -97,6 +108,23 @@ export function DynamicForm({
97
108
  return false
98
109
  }, [visibleFields, values])
99
110
 
111
+ // Group visible fields by their form_layout section (empty sections drop out
112
+ // because visibleFields is already visibility-filtered). Without a layout
113
+ // this is a single default group → the render below collapses to the legacy
114
+ // flat grid, byte-for-byte.
115
+ const groups = useMemo(
116
+ () => groupFieldsBySection(visibleFields, formLayout, values),
117
+ [visibleFields, formLayout, values],
118
+ )
119
+ const isSteps = formLayout?.mode === 'steps' && groups.length > 1
120
+
121
+ // Wizard step cursor (steps mode only). Clamped whenever the group count
122
+ // shrinks (a visible_when flip can empty a trailing step's section).
123
+ const [stepIndex, setStepIndex] = useState(0)
124
+ useEffect(() => {
125
+ setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0)))
126
+ }, [groups.length])
127
+
100
128
  useEffect(() => {
101
129
  const defaults: Record<string, any> = {}
102
130
  for (const f of editableFields) {
@@ -131,25 +159,93 @@ export function DynamicForm({
131
159
  try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
132
160
  }
133
161
 
162
+ // Renders one group's fields into the responsive 2-column grid: scalar
163
+ // header fields flow through it; line-items grids (and textareas) span full
164
+ // width so the row table / memo gets room. Shared by every layout mode.
165
+ const renderGrid = (groupFields: ActionFieldDef[]) => (
166
+ <div className="grid gap-4 sm:grid-cols-2">
167
+ {groupFields.map((field) => (
168
+ <FieldRow
169
+ key={field.key}
170
+ field={field}
171
+ value={values[field.key]}
172
+ onChange={(v: any) => update(field.key, v)}
173
+ values={values}
174
+ error={errors[field.key]}
175
+ initialValues={initialValues}
176
+ />
177
+ ))}
178
+ </div>
179
+ )
180
+
181
+ // ── Steps (wizard) mode ────────────────────────────────────────────────
182
+ // One step per section; Anterior/Siguiente navigate, submit only on the
183
+ // last step. Advancing validates just the current step's fields so a later
184
+ // step can't be blocked by an earlier untouched one and vice-versa.
185
+ if (isSteps) {
186
+ const step = groups[stepIndex]
187
+ const isLast = stepIndex === groups.length - 1
188
+
189
+ const goNext = () => {
190
+ const stepSchema = buildZodSchema(step.fields)
191
+ const result = stepSchema.safeParse(values)
192
+ if (!result.success) {
193
+ const next: Record<string, string> = {}
194
+ for (const issue of result.error.issues) {
195
+ const key = issue.path[0]
196
+ if (typeof key === 'string' && !next[key]) next[key] = issue.message
197
+ }
198
+ setErrors(next)
199
+ return
200
+ }
201
+ setErrors({})
202
+ setStepIndex((i) => Math.min(i + 1, groups.length - 1))
203
+ }
204
+ const goBack = () => setStepIndex((i) => Math.max(i - 1, 0))
205
+
206
+ return (
207
+ <form onSubmit={handleSubmit} className="grid gap-4">
208
+ <WizardProgress groups={groups} stepIndex={stepIndex} />
209
+ {renderGrid(step.fields)}
210
+ <div className="flex justify-between gap-2 pt-2">
211
+ {stepIndex > 0 ? (
212
+ <Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
213
+ Anterior
214
+ </Button>
215
+ ) : onCancel ? (
216
+ <Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
217
+ {cancelLabel}
218
+ </Button>
219
+ ) : (
220
+ <span />
221
+ )}
222
+ {isLast ? (
223
+ <Button type="submit" disabled={submitting || disabled || balanceBlocked}>
224
+ {submitLabel}
225
+ </Button>
226
+ ) : (
227
+ <Button type="button" onClick={goNext} disabled={submitting || disabled}>
228
+ Siguiente
229
+ </Button>
230
+ )}
231
+ </div>
232
+ </form>
233
+ )
234
+ }
235
+
236
+ // ── Sections / flat mode ───────────────────────────────────────────────
134
237
  // Layout: scalar header fields flow through a responsive 2-column grid;
135
238
  // line-items grids (and textareas) span the full width so the row table /
136
239
  // memo gets room. Mirrors the pro look of the federated journal modal but
137
- // stays fully declarative — driven only by field shape.
240
+ // stays fully declarative — driven only by field shape. With no layout this
241
+ // is a single default group rendered without section chrome (unchanged).
138
242
  return (
139
243
  <form onSubmit={handleSubmit} className="grid gap-4">
140
- <div className="grid gap-4 sm:grid-cols-2">
141
- {visibleFields.map((field) => (
142
- <FieldRow
143
- key={field.key}
144
- field={field}
145
- value={values[field.key]}
146
- onChange={(v: any) => update(field.key, v)}
147
- values={values}
148
- error={errors[field.key]}
149
- initialValues={initialValues}
150
- />
151
- ))}
152
- </div>
244
+ {groups.map((group) => (
245
+ <FieldSection key={group.key} group={group}>
246
+ {renderGrid(group.fields)}
247
+ </FieldSection>
248
+ ))}
153
249
  <div className="flex justify-end gap-2 pt-2">
154
250
  {onCancel && (
155
251
  <Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
@@ -0,0 +1,117 @@
1
+ // Presentational chrome for declarative form layouts (see `form-layout.ts`).
2
+ // Kept apart from the grouping logic so the pure helper stays React-free and the
3
+ // two renderers (`dynamic-form.tsx`, `dialogs/dynamic-record.tsx`) share one
4
+ // look for sections and the wizard progress bar.
5
+ import { useState } from 'react'
6
+ import {
7
+ Collapsible,
8
+ CollapsibleContent,
9
+ CollapsibleTrigger,
10
+ Button,
11
+ } from '@asteby/metacore-ui/primitives'
12
+ import { ChevronDown } from 'lucide-react'
13
+ import type { FieldGroup } from './form-layout'
14
+
15
+ /**
16
+ * Section chrome for `mode: "sections"`. Wraps a group's (already gridded)
17
+ * fields in a titled block.
18
+ * - The default/orphan group renders with NO chrome, so a layout-less form (one
19
+ * default group) is visually identical to the legacy flat list.
20
+ * - A section whose `collapsed` is defined (true/false) is rendered COLLAPSIBLE,
21
+ * starting collapsed when `collapsed === true`. A section with no `collapsed`
22
+ * flag renders as a plain, always-open titled block.
23
+ */
24
+ export function FieldSection({
25
+ group,
26
+ children,
27
+ }: {
28
+ group: FieldGroup<unknown>
29
+ children: React.ReactNode
30
+ }) {
31
+ const collapsible = group.collapsed !== undefined
32
+ const [open, setOpen] = useState(!group.collapsed)
33
+
34
+ if (group.isDefault) return <>{children}</>
35
+
36
+ const header = (
37
+ <div className="min-w-0 text-left">
38
+ {group.title && (
39
+ <h3 className="text-sm font-semibold leading-none">{group.title}</h3>
40
+ )}
41
+ {group.description && (
42
+ <p className="pt-1 text-sm text-muted-foreground">{group.description}</p>
43
+ )}
44
+ </div>
45
+ )
46
+
47
+ if (!collapsible) {
48
+ return (
49
+ <section className="grid gap-3">
50
+ {(group.title || group.description) && header}
51
+ {children}
52
+ </section>
53
+ )
54
+ }
55
+
56
+ return (
57
+ <Collapsible open={open} onOpenChange={setOpen} className="grid gap-3">
58
+ <CollapsibleTrigger asChild>
59
+ <Button
60
+ type="button"
61
+ variant="ghost"
62
+ className="h-auto w-full justify-between px-0 py-1 hover:bg-transparent"
63
+ >
64
+ {header}
65
+ <ChevronDown
66
+ className={
67
+ 'h-4 w-4 shrink-0 transition-transform ' + (open ? 'rotate-180' : '')
68
+ }
69
+ />
70
+ </Button>
71
+ </CollapsibleTrigger>
72
+ <CollapsibleContent className="grid gap-3">{children}</CollapsibleContent>
73
+ </Collapsible>
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Progress bar for `mode: "steps"`: one filled segment per completed/current
79
+ * step plus a "Paso i/n · <title>" caption. Mirrors the WizardActionModal look
80
+ * so a model wizard and an action wizard read the same.
81
+ */
82
+ export function WizardProgress({
83
+ groups,
84
+ stepIndex,
85
+ stepLabel = 'Paso',
86
+ }: {
87
+ groups: FieldGroup<unknown>[]
88
+ stepIndex: number
89
+ stepLabel?: string
90
+ }) {
91
+ const current = groups[stepIndex]
92
+ return (
93
+ <div className="pt-2">
94
+ <div className="flex items-center gap-1.5" role="list" aria-label="progress">
95
+ {groups.map((g, i) => (
96
+ <div
97
+ key={g.key}
98
+ role="listitem"
99
+ aria-current={i === stepIndex ? 'step' : undefined}
100
+ className="h-1.5 flex-1 rounded-full"
101
+ style={{
102
+ backgroundColor:
103
+ i <= stepIndex ? 'hsl(var(--primary))' : 'hsl(var(--muted))',
104
+ }}
105
+ />
106
+ ))}
107
+ </div>
108
+ <p className="pt-2 text-sm text-muted-foreground">
109
+ {stepLabel} {stepIndex + 1}/{groups.length}
110
+ {current?.title ? ` · ${current.title}` : ''}
111
+ </p>
112
+ {current?.description && (
113
+ <p className="pt-1 text-sm text-muted-foreground">{current.description}</p>
114
+ )}
115
+ </div>
116
+ )
117
+ }