@uniform-ts/core 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -14,7 +14,7 @@ UniForm takes a Zod schema and automatically renders a fully customizable form.
14
14
  - **Per-field `onChange` in `fields` prop** — react to individual field changes inline, with typed values and full form control methods
15
15
  - **Per-field custom components** — pass any `React.ComponentType<FieldProps>` directly as `meta.component` (inline, no registry) or register under a custom string key; direct components bypass the registry _and_ the default `ArrayField`/`ObjectField` routing, allowing fully custom multi-value widgets for `array`-typed fields
16
16
  - **Layout hooks** — `classNames`, `fieldWrapper`, `layout.formWrapper`, `layout.sectionWrapper`, `layout.submitButton`
17
- - **Section grouping** — group fields into named sections via `meta.section`
17
+ - **Section grouping** — group fields into named sections via `meta.section`; style or swap individual section wrappers via `layout.sections`
18
18
  - **Conditional fields** — show/hide fields based on form values with `meta.condition`; hidden fields automatically reset to their default value
19
19
  - **Field ordering** — control render order with `meta.order`
20
20
  - **`createAutoForm()` factory** — bake in your design system defaults once, use everywhere
@@ -178,7 +178,7 @@ const MyAutoForm = createAutoForm({
178
178
  | -------------- | ---------------------------------------- | ------------------------------------------------ |
179
179
  | `components` | `ComponentRegistry` | Deep merge (instance overrides specific keys) |
180
180
  | `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Instance replaces factory |
181
- | `layout` | `LayoutSlots` | Shallow merge |
181
+ | `layout` | `LayoutSlots` | Shallow merge (except `sections` — deep-merged) |
182
182
  | `classNames` | `FormClassNames` | Shallow merge |
183
183
  | `disabled` | `boolean` | OR logic (either `true` → disabled) |
184
184
  | `coercions` | `CoercionMap` | Shallow merge |
@@ -307,11 +307,29 @@ type LayoutSlots = {
307
307
  sectionWrapper?: React.ComponentType<{
308
308
  children: React.ReactNode
309
309
  title: string
310
+ className?: string
310
311
  }>
311
312
  submitButton?: React.ComponentType<{ isSubmitting: boolean }>
312
313
  arrayRowLayout?: React.ComponentType<ArrayRowLayoutProps>
313
314
  /** Shown while async `defaultValues` are resolving. Default: `<p>Loading…</p>` */
314
315
  loadingFallback?: React.ReactNode
316
+ /** Per-section styling / component overrides keyed by section title. */
317
+ sections?: Record<string, SectionConfig>
318
+ }
319
+ ```
320
+
321
+ #### `SectionConfig`
322
+
323
+ ```ts
324
+ type SectionConfig = {
325
+ /** CSS class name forwarded to the section wrapper. */
326
+ className?: string
327
+ /** Replace the section wrapper component for this section only. */
328
+ component?: React.ComponentType<{
329
+ children: React.ReactNode
330
+ title: string
331
+ className?: string
332
+ }>
315
333
  }
316
334
  ```
317
335
 
@@ -468,7 +486,26 @@ const schema = z.object({
468
486
 
469
487
  Zod still validates the array (`.min(1)` etc.) — only the _render_ is taken over by your component.
470
488
 
471
- #### Option 2 — Named key in the registry
489
+ #### Option 2 — String field as select
490
+
491
+ A `z.string()` field can be rendered as a select by setting `meta.component: 'select'` together with `meta.options`. UniForm treats it as type `"select"` during introspection:
492
+
493
+ ```ts
494
+ const schema = z.object({
495
+ role: z.string().meta({
496
+ component: 'select',
497
+ options: [
498
+ { label: 'User', value: 'user' },
499
+ { label: 'Admin', value: 'admin' },
500
+ { label: 'Editor', value: 'editor' },
501
+ ],
502
+ }),
503
+ })
504
+ ```
505
+
506
+ This is an alternative to `z.enum(...)` — useful when the option list is dynamic or when you need a plain `string` output type rather than a union literal.
507
+
508
+ #### Option 3 — Named key in the registry
472
509
 
473
510
  Register a component under a custom string key — either in `createAutoForm` or the `components` prop — then reference it with `meta.component: 'yourKey'`:
474
511
 
@@ -614,8 +651,8 @@ The `span` value is set as `--field-span` CSS custom property on each field wrap
614
651
  city: { section: 'Address', order: 4 },
615
652
  }}
616
653
  layout={{
617
- sectionWrapper: ({ children, title }) => (
618
- <fieldset>
654
+ sectionWrapper: ({ children, title, className }) => (
655
+ <fieldset className={className}>
619
656
  <legend>{title}</legend>
620
657
  {children}
621
658
  </fieldset>
@@ -624,6 +661,23 @@ The `span` value is set as `--field-span` CSS custom property on each field wrap
624
661
  />
625
662
  ```
626
663
 
664
+ Use `layout.sections` to style or swap the wrapper for individual sections:
665
+
666
+ ```tsx
667
+ <AutoForm
668
+ form={myForm}
669
+ onSubmit={handleSubmit}
670
+ layout={{
671
+ sections: {
672
+ Personal: { className: 'bg-blue-50 p-4 rounded' },
673
+ Address: { component: AddressCard }, // completely different component
674
+ },
675
+ }}
676
+ />
677
+ ```
678
+
679
+ `className` is forwarded as a prop to the active wrapper (global `sectionWrapper` or the per-section `component`). Factory-level and instance-level `sections` are merged — instance wins on conflicts.
680
+
627
681
  ### Conditional Fields
628
682
 
629
683
  Show a field only when another field has a specific value. Conditional fields are fully lifecycle-managed:
package/dist/index.d.mts CHANGED
@@ -181,6 +181,26 @@ type FieldConfigBase = {
181
181
  required: boolean;
182
182
  /** Merged UI metadata for the field. */
183
183
  meta: FieldMeta;
184
+ /**
185
+ * The original Zod schema for this field, after transparent wrappers
186
+ * (`optional`, `nullable`, `default`, `pipe`) have been stripped.
187
+ *
188
+ * This is a general escape hatch for custom components that need to inspect
189
+ * the raw schema — for example, to read union variants, access custom Zod
190
+ * metadata not captured by introspection, or build schema-aware validation UI.
191
+ *
192
+ * @example
193
+ * function MyUnionInput({ field }: FieldProps) {
194
+ * // Inspect the original schema for any edge case
195
+ * const schema = field.schema
196
+ * if (schema._zod.def.type === 'union') {
197
+ * const variants = (schema._zod.def as z.$ZodUnionDef).options
198
+ * // render a type-switcher using the raw Zod variants
199
+ * }
200
+ * return <input ... />
201
+ * }
202
+ */
203
+ schema: z.$ZodType;
184
204
  };
185
205
  /**
186
206
  * The fully resolved configuration for a single form field, produced by
@@ -256,6 +276,12 @@ type FieldProps = {
256
276
  options?: SelectOption[];
257
277
  /** Full field metadata, including any custom keys. */
258
278
  meta: FieldMeta;
279
+ /**
280
+ * The original Zod schema for this field (after transparent wrappers are stripped).
281
+ * Use this as an escape hatch when you need capabilities beyond what `FieldConfig`
282
+ * exposes — e.g. inspecting union variants, accessing custom Zod refinements, etc.
283
+ */
284
+ schema: z.$ZodType;
259
285
  };
260
286
  /**
261
287
  * A map of field type keys to React components used to render them.
@@ -321,6 +347,20 @@ type ArrayRowLayoutProps = {
321
347
  /** Total number of rows currently in the array. */
322
348
  rowCount: number;
323
349
  };
350
+ /**
351
+ * Per-section styling overrides forwarded to the `sectionWrapper` component.
352
+ * Keys are section titles; values control how that section wrapper is styled.
353
+ */
354
+ type SectionConfig = {
355
+ /** CSS class name(s) applied to the section wrapper. */
356
+ className?: string;
357
+ /** Replaces the section wrapper component entirely for this section. */
358
+ component?: React.ComponentType<{
359
+ children: React.ReactNode;
360
+ title: string;
361
+ className?: string;
362
+ }>;
363
+ };
324
364
  /**
325
365
  * Optional layout slot overrides for top-level structural components of the
326
366
  * form. Provide only the slots you want to replace; omitted slots fall back
@@ -335,6 +375,7 @@ type LayoutSlots = {
335
375
  sectionWrapper?: React.ComponentType<{
336
376
  children: React.ReactNode;
337
377
  title: string;
378
+ className?: string;
338
379
  }>;
339
380
  /** Custom submit button component. */
340
381
  submitButton?: React.ComponentType<{
@@ -348,6 +389,11 @@ type LayoutSlots = {
348
389
  * Defaults to a simple `<p>Loading…</p>` when not provided.
349
390
  */
350
391
  loadingFallback?: React.ReactNode;
392
+ /**
393
+ * Per-section config keyed by section title.
394
+ * Forwarded to the `sectionWrapper` component as a `className` prop.
395
+ */
396
+ sections?: Record<string, SectionConfig>;
351
397
  };
352
398
  /**
353
399
  * The resolved version of `LayoutSlots` used internally, where all slots are
@@ -360,6 +406,7 @@ type ResolvedLayoutSlots = {
360
406
  sectionWrapper: React.ComponentType<{
361
407
  children: React.ReactNode;
362
408
  title: string;
409
+ className?: string;
363
410
  }>;
364
411
  submitButton: React.ComponentType<{
365
412
  isSubmitting: boolean;
package/dist/index.d.ts CHANGED
@@ -181,6 +181,26 @@ type FieldConfigBase = {
181
181
  required: boolean;
182
182
  /** Merged UI metadata for the field. */
183
183
  meta: FieldMeta;
184
+ /**
185
+ * The original Zod schema for this field, after transparent wrappers
186
+ * (`optional`, `nullable`, `default`, `pipe`) have been stripped.
187
+ *
188
+ * This is a general escape hatch for custom components that need to inspect
189
+ * the raw schema — for example, to read union variants, access custom Zod
190
+ * metadata not captured by introspection, or build schema-aware validation UI.
191
+ *
192
+ * @example
193
+ * function MyUnionInput({ field }: FieldProps) {
194
+ * // Inspect the original schema for any edge case
195
+ * const schema = field.schema
196
+ * if (schema._zod.def.type === 'union') {
197
+ * const variants = (schema._zod.def as z.$ZodUnionDef).options
198
+ * // render a type-switcher using the raw Zod variants
199
+ * }
200
+ * return <input ... />
201
+ * }
202
+ */
203
+ schema: z.$ZodType;
184
204
  };
185
205
  /**
186
206
  * The fully resolved configuration for a single form field, produced by
@@ -256,6 +276,12 @@ type FieldProps = {
256
276
  options?: SelectOption[];
257
277
  /** Full field metadata, including any custom keys. */
258
278
  meta: FieldMeta;
279
+ /**
280
+ * The original Zod schema for this field (after transparent wrappers are stripped).
281
+ * Use this as an escape hatch when you need capabilities beyond what `FieldConfig`
282
+ * exposes — e.g. inspecting union variants, accessing custom Zod refinements, etc.
283
+ */
284
+ schema: z.$ZodType;
259
285
  };
260
286
  /**
261
287
  * A map of field type keys to React components used to render them.
@@ -321,6 +347,20 @@ type ArrayRowLayoutProps = {
321
347
  /** Total number of rows currently in the array. */
322
348
  rowCount: number;
323
349
  };
350
+ /**
351
+ * Per-section styling overrides forwarded to the `sectionWrapper` component.
352
+ * Keys are section titles; values control how that section wrapper is styled.
353
+ */
354
+ type SectionConfig = {
355
+ /** CSS class name(s) applied to the section wrapper. */
356
+ className?: string;
357
+ /** Replaces the section wrapper component entirely for this section. */
358
+ component?: React.ComponentType<{
359
+ children: React.ReactNode;
360
+ title: string;
361
+ className?: string;
362
+ }>;
363
+ };
324
364
  /**
325
365
  * Optional layout slot overrides for top-level structural components of the
326
366
  * form. Provide only the slots you want to replace; omitted slots fall back
@@ -335,6 +375,7 @@ type LayoutSlots = {
335
375
  sectionWrapper?: React.ComponentType<{
336
376
  children: React.ReactNode;
337
377
  title: string;
378
+ className?: string;
338
379
  }>;
339
380
  /** Custom submit button component. */
340
381
  submitButton?: React.ComponentType<{
@@ -348,6 +389,11 @@ type LayoutSlots = {
348
389
  * Defaults to a simple `<p>Loading…</p>` when not provided.
349
390
  */
350
391
  loadingFallback?: React.ReactNode;
392
+ /**
393
+ * Per-section config keyed by section title.
394
+ * Forwarded to the `sectionWrapper` component as a `className` prop.
395
+ */
396
+ sections?: Record<string, SectionConfig>;
351
397
  };
352
398
  /**
353
399
  * The resolved version of `LayoutSlots` used internally, where all slots are
@@ -360,6 +406,7 @@ type ResolvedLayoutSlots = {
360
406
  sectionWrapper: React.ComponentType<{
361
407
  children: React.ReactNode;
362
408
  title: string;
409
+ className?: string;
363
410
  }>;
364
411
  submitButton: React.ComponentType<{
365
412
  isSubmitting: boolean;
package/dist/index.js CHANGED
@@ -83,25 +83,30 @@ function introspectSchema(schema, name = "", parentPath = "") {
83
83
  let maxItems;
84
84
  try {
85
85
  if (kind === "string") {
86
- type = "string";
87
- const defFormat = def.format;
88
- if (defFormat === "email") {
89
- mergedMeta["inputType"] = "email";
90
- } else if (defFormat === "url") {
91
- mergedMeta["inputType"] = "url";
92
- } else if (defFormat === "uuid") {
93
- mergedMeta["inputType"] = "uuid";
86
+ if (mergedMeta.component === "select" && Array.isArray(mergedMeta.options) && mergedMeta.options.length > 0) {
87
+ type = "select";
88
+ options = mergedMeta.options;
94
89
  } else {
95
- const checks = def.checks ?? [];
96
- const hasFormat = (fmt) => checks.some(
97
- (c) => c._zod.def.check === "string_format" && c._zod.def.format === fmt
98
- );
99
- if (hasFormat("email")) {
90
+ type = "string";
91
+ const defFormat = def.format;
92
+ if (defFormat === "email") {
100
93
  mergedMeta["inputType"] = "email";
101
- } else if (hasFormat("url")) {
94
+ } else if (defFormat === "url") {
102
95
  mergedMeta["inputType"] = "url";
103
- } else if (hasFormat("uuid")) {
96
+ } else if (defFormat === "uuid") {
104
97
  mergedMeta["inputType"] = "uuid";
98
+ } else {
99
+ const checks = def.checks ?? [];
100
+ const hasFormat = (fmt) => checks.some(
101
+ (c) => c._zod.def.check === "string_format" && c._zod.def.format === fmt
102
+ );
103
+ if (hasFormat("email")) {
104
+ mergedMeta["inputType"] = "email";
105
+ } else if (hasFormat("url")) {
106
+ mergedMeta["inputType"] = "url";
107
+ } else if (hasFormat("uuid")) {
108
+ mergedMeta["inputType"] = "uuid";
109
+ }
105
110
  }
106
111
  }
107
112
  } else if (kind === "number") {
@@ -138,15 +143,24 @@ function introspectSchema(schema, name = "", parentPath = "") {
138
143
  }
139
144
  }
140
145
  } else if (def.type === "union") {
141
- type = "union";
142
146
  const unionDef = def;
147
+ const variants = unionDef.options;
143
148
  if ("discriminator" in unionDef) {
149
+ type = "union";
144
150
  discriminatorKey = unionDef.discriminator;
151
+ unionVariants = variants.map(
152
+ (variant, i) => introspectSchema(variant, String(i), path)
153
+ );
154
+ } else {
155
+ const collapsed = introspectSchema(variants[0], name, parentPath);
156
+ return {
157
+ ...collapsed,
158
+ name: path,
159
+ label,
160
+ meta: { ...collapsed.meta, ...mergedMeta },
161
+ schema: inner
162
+ };
145
163
  }
146
- const variants = unionDef.options;
147
- unionVariants = variants.map(
148
- (variant, i) => introspectSchema(variant, String(i), path)
149
- );
150
164
  }
151
165
  } catch {
152
166
  type = "unknown";
@@ -157,6 +171,7 @@ function introspectSchema(schema, name = "", parentPath = "") {
157
171
  label,
158
172
  required,
159
173
  meta: mergedMeta,
174
+ schema: inner,
160
175
  ...options !== void 0 && { options },
161
176
  ...children !== void 0 && { children },
162
177
  ...itemConfig !== void 0 && { itemConfig },
@@ -195,6 +210,7 @@ function parseDiscriminatedUnionMeta(schema) {
195
210
  label: deriveLabel(discriminatorKey),
196
211
  required: true,
197
212
  meta: {},
213
+ schema,
198
214
  options: discriminatorOptions
199
215
  };
200
216
  return {
@@ -382,9 +398,10 @@ function DefaultFormWrapper({ children }) {
382
398
  }
383
399
  function DefaultSectionWrapper({
384
400
  children,
385
- title
401
+ title,
402
+ className
386
403
  }) {
387
- return /* @__PURE__ */ jsxRuntime.jsxs("fieldset", { children: [
404
+ return /* @__PURE__ */ jsxRuntime.jsxs("fieldset", { className, children: [
388
405
  /* @__PURE__ */ jsxRuntime.jsx("legend", { children: title }),
389
406
  children
390
407
  ] });
@@ -495,7 +512,7 @@ function ScalarField({
495
512
  onChange: (value) => {
496
513
  const coerced = coerceValue(field.type, value, coercions);
497
514
  rhfField.onChange(coerced);
498
- field.meta.onChange?.(coerced, formMethods);
515
+ void field.meta.onChange?.(coerced, formMethods);
499
516
  },
500
517
  onBlur: rhfField.onBlur,
501
518
  ref: rhfField.ref,
@@ -505,7 +522,9 @@ function ScalarField({
505
522
  error: fieldState.error?.message,
506
523
  required: field.required,
507
524
  disabled: field.meta.disabled || contextDisabled,
508
- meta: field.meta
525
+ options: field.meta.options,
526
+ meta: field.meta,
527
+ schema: field.schema
509
528
  }
510
529
  )
511
530
  }
@@ -537,7 +556,7 @@ function BooleanField({
537
556
  value: rhfField.value ?? false,
538
557
  onChange: (value) => {
539
558
  rhfField.onChange(value);
540
- field.meta.onChange?.(value, formMethods);
559
+ void field.meta.onChange?.(value, formMethods);
541
560
  },
542
561
  onBlur: rhfField.onBlur,
543
562
  ref: rhfField.ref,
@@ -547,7 +566,8 @@ function BooleanField({
547
566
  error: fieldState.error?.message,
548
567
  required: field.required,
549
568
  disabled: field.meta.disabled || rhfField.disabled || contextDisabled,
550
- meta: field.meta
569
+ meta: field.meta,
570
+ schema: field.schema
551
571
  }
552
572
  )
553
573
  }
@@ -579,7 +599,7 @@ function SelectField({
579
599
  value: rhfField.value ?? "",
580
600
  onChange: (value) => {
581
601
  rhfField.onChange(value);
582
- field.meta.onChange?.(value, formMethods);
602
+ void field.meta.onChange?.(value, formMethods);
583
603
  },
584
604
  onBlur: rhfField.onBlur,
585
605
  ref: rhfField.ref,
@@ -590,7 +610,8 @@ function SelectField({
590
610
  required: field.required,
591
611
  disabled: field.meta.disabled || contextDisabled,
592
612
  options: field.options,
593
- meta: field.meta
613
+ meta: field.meta,
614
+ schema: field.schema
594
615
  }
595
616
  )
596
617
  }
@@ -1505,7 +1526,17 @@ function AutoForm(props) {
1505
1526
  if (section.title === null) {
1506
1527
  return /* @__PURE__ */ jsxRuntime.jsx(React3__namespace.Fragment, { children: renderedFields }, "__ungrouped");
1507
1528
  }
1508
- return /* @__PURE__ */ jsxRuntime.jsx(SectionWrapper, { title: section.title, children: renderedFields }, section.title);
1529
+ const sectionConfig = layout?.sections?.[section.title];
1530
+ const PerSectionWrapper = sectionConfig?.component ?? SectionWrapper;
1531
+ return /* @__PURE__ */ jsxRuntime.jsx(
1532
+ PerSectionWrapper,
1533
+ {
1534
+ title: section.title,
1535
+ className: sectionConfig?.className,
1536
+ children: renderedFields
1537
+ },
1538
+ section.title
1539
+ );
1509
1540
  }),
1510
1541
  /* @__PURE__ */ jsxRuntime.jsx(
1511
1542
  SubmitButton,
@@ -1525,7 +1556,11 @@ function createAutoForm(config) {
1525
1556
  [props.components]
1526
1557
  );
1527
1558
  const mergedLayout = React3__namespace.useMemo(
1528
- () => ({ ...config.layout, ...props.layout }),
1559
+ () => ({
1560
+ ...config.layout,
1561
+ ...props.layout,
1562
+ sections: config.layout?.sections || props.layout?.sections ? { ...config.layout?.sections, ...props.layout?.sections } : void 0
1563
+ }),
1529
1564
  [props.layout]
1530
1565
  );
1531
1566
  const mergedClassNames = React3__namespace.useMemo(