@xemahq/ui-kernel 0.6.1 → 0.7.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.
Files changed (71) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/ui/capture/document-preview.d.ts +12 -0
  6. package/dist/ui/capture/document-preview.d.ts.map +1 -0
  7. package/dist/ui/capture/document-preview.js +31 -0
  8. package/dist/ui/capture/document-preview.js.map +1 -0
  9. package/dist/ui/capture/field-spec-to-zod.d.ts +5 -0
  10. package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -0
  11. package/dist/ui/capture/field-spec-to-zod.js +71 -0
  12. package/dist/ui/capture/field-spec-to-zod.js.map +1 -0
  13. package/dist/ui/capture/field-spec.d.ts +49 -0
  14. package/dist/ui/capture/field-spec.d.ts.map +1 -0
  15. package/dist/ui/capture/field-spec.js +59 -0
  16. package/dist/ui/capture/field-spec.js.map +1 -0
  17. package/dist/ui/capture/index.d.ts +11 -0
  18. package/dist/ui/capture/index.d.ts.map +1 -0
  19. package/dist/ui/capture/index.js +31 -0
  20. package/dist/ui/capture/index.js.map +1 -0
  21. package/dist/ui/capture/schema-field-input.d.ts +20 -0
  22. package/dist/ui/capture/schema-field-input.d.ts.map +1 -0
  23. package/dist/ui/capture/schema-field-input.js +64 -0
  24. package/dist/ui/capture/schema-field-input.js.map +1 -0
  25. package/dist/ui/capture/schema-form.d.ts +18 -0
  26. package/dist/ui/capture/schema-form.d.ts.map +1 -0
  27. package/dist/ui/capture/schema-form.js +26 -0
  28. package/dist/ui/capture/schema-form.js.map +1 -0
  29. package/dist/ui/capture/use-prefill.d.ts +18 -0
  30. package/dist/ui/capture/use-prefill.d.ts.map +1 -0
  31. package/dist/ui/capture/use-prefill.js +94 -0
  32. package/dist/ui/capture/use-prefill.js.map +1 -0
  33. package/dist/ui/capture/use-schema-validation.d.ts +11 -0
  34. package/dist/ui/capture/use-schema-validation.d.ts.map +1 -0
  35. package/dist/ui/capture/use-schema-validation.js +69 -0
  36. package/dist/ui/capture/use-schema-validation.js.map +1 -0
  37. package/dist/ui/capture/use-wizard.d.ts +28 -0
  38. package/dist/ui/capture/use-wizard.d.ts.map +1 -0
  39. package/dist/ui/capture/use-wizard.js +63 -0
  40. package/dist/ui/capture/use-wizard.js.map +1 -0
  41. package/dist/ui/capture/value-format.d.ts +4 -0
  42. package/dist/ui/capture/value-format.d.ts.map +1 -0
  43. package/dist/ui/capture/value-format.js +23 -0
  44. package/dist/ui/capture/value-format.js.map +1 -0
  45. package/dist/ui/capture/wizard.d.ts +19 -0
  46. package/dist/ui/capture/wizard.d.ts.map +1 -0
  47. package/dist/ui/capture/wizard.js +34 -0
  48. package/dist/ui/capture/wizard.js.map +1 -0
  49. package/dist/ui/index.d.ts +1 -0
  50. package/dist/ui/index.d.ts.map +1 -1
  51. package/dist/ui/index.js +1 -0
  52. package/dist/ui/index.js.map +1 -1
  53. package/dist/ui/primitives/badge.d.ts +1 -1
  54. package/dist/ui/primitives/button.d.ts +1 -1
  55. package/dist/ui/primitives/sheet.d.ts +1 -1
  56. package/package.json +4 -3
  57. package/src/index.ts +5 -0
  58. package/src/ui/capture/document-preview.tsx +101 -0
  59. package/src/ui/capture/field-spec-to-zod.test.ts +59 -0
  60. package/src/ui/capture/field-spec-to-zod.ts +93 -0
  61. package/src/ui/capture/field-spec.test.ts +49 -0
  62. package/src/ui/capture/field-spec.ts +160 -0
  63. package/src/ui/capture/index.ts +43 -0
  64. package/src/ui/capture/schema-field-input.tsx +186 -0
  65. package/src/ui/capture/schema-form.tsx +108 -0
  66. package/src/ui/capture/use-prefill.ts +150 -0
  67. package/src/ui/capture/use-schema-validation.ts +113 -0
  68. package/src/ui/capture/use-wizard.ts +134 -0
  69. package/src/ui/capture/value-format.ts +22 -0
  70. package/src/ui/capture/wizard.tsx +100 -0
  71. package/src/ui/index.ts +3 -0
@@ -0,0 +1,59 @@
1
+ // Pure-logic proofs for the CaptureFieldSpec → zod adapter. Runs under the
2
+ // host-web vitest runner (this package carries no vitest dep — see tsconfig
3
+ // exclude), mirroring the repo convention used by i18n-port.test.ts.
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { CaptureFieldType, type CaptureFieldSpec } from './field-spec';
7
+ import { fieldSpecToZod, isEmptyValue } from './field-spec-to-zod';
8
+
9
+ describe('isEmptyValue', () => {
10
+ it('treats undefined/null/empty-string/empty-array as absent', () => {
11
+ expect(isEmptyValue(undefined)).toBe(true);
12
+ expect(isEmptyValue(null)).toBe(true);
13
+ expect(isEmptyValue('')).toBe(true);
14
+ expect(isEmptyValue([])).toBe(true);
15
+ expect(isEmptyValue('x')).toBe(false);
16
+ expect(isEmptyValue(0)).toBe(false);
17
+ expect(isEmptyValue(false)).toBe(false);
18
+ });
19
+ });
20
+
21
+ describe('fieldSpecToZod', () => {
22
+ const fields: CaptureFieldSpec[] = [
23
+ { key: 'title', label: 'Title', type: CaptureFieldType.String, required: true },
24
+ { key: 'count', label: 'Count', type: CaptureFieldType.Number, required: false, min: 0 },
25
+ { key: 'status', label: 'Status', type: CaptureFieldType.Enum, enumValues: ['open', 'closed'], required: true },
26
+ {
27
+ key: 'tags',
28
+ label: 'Tags',
29
+ type: CaptureFieldType.List,
30
+ min: 1,
31
+ fields: [{ key: 'name', label: 'Name', type: CaptureFieldType.String, required: true }],
32
+ },
33
+ ];
34
+
35
+ it('accepts a valid record', () => {
36
+ const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', count: 3, status: 'open', tags: [{ name: 'a' }] });
37
+ expect(r.success).toBe(true);
38
+ });
39
+
40
+ it('rejects an empty required string', () => {
41
+ const r = fieldSpecToZod(fields).safeParse({ title: '', status: 'open', tags: [{ name: 'a' }] });
42
+ expect(r.success).toBe(false);
43
+ });
44
+
45
+ it('allows an omitted optional field', () => {
46
+ const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'closed', tags: [{ name: 'a' }] });
47
+ expect(r.success).toBe(true);
48
+ });
49
+
50
+ it('rejects an enum value outside the set', () => {
51
+ const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'bogus', tags: [{ name: 'a' }] });
52
+ expect(r.success).toBe(false);
53
+ });
54
+
55
+ it('enforces list min and nested required item fields', () => {
56
+ expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [] }).success).toBe(false);
57
+ expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [{ name: '' }] }).success).toBe(false);
58
+ });
59
+ });
@@ -0,0 +1,93 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // CaptureFieldSpec → Zod adapter
3
+ //
4
+ // Bridges the JSON-Schema-like field spec (name/type/required/enum, and the
5
+ // nested object/list composites) to a `zod` schema so `useSchemaValidation`
6
+ // works with app definitions, not only hand-written zod. Deterministic and
7
+ // pure — no side effects, safe to memoize on the spec identity.
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ import { z } from 'zod';
10
+
11
+ import { CaptureFieldType, type CaptureFieldSpec, type CaptureSchema, type CaptureSection, toFieldList } from './field-spec';
12
+
13
+ /** `''`, `null`, `undefined` all mean "absent" at the capture boundary. */
14
+ export function isEmptyValue(value: unknown): boolean {
15
+ return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0);
16
+ }
17
+
18
+ function scalarSchema(field: CaptureFieldSpec): z.ZodTypeAny {
19
+ switch (field.type) {
20
+ case CaptureFieldType.Number: {
21
+ let s = z.number({ message: `${field.label} must be a number` });
22
+ if (typeof field.min === 'number') s = s.min(field.min, { message: `${field.label} must be ≥ ${field.min}` });
23
+ if (typeof field.max === 'number') s = s.max(field.max, { message: `${field.label} must be ≤ ${field.max}` });
24
+ return s;
25
+ }
26
+ case CaptureFieldType.Boolean:
27
+ return z.boolean({ message: `${field.label} must be true or false` });
28
+ case CaptureFieldType.Decimal:
29
+ // NUMERIC carried as a string to avoid float precision loss.
30
+ return z.string().regex(/^-?\d+(\.\d+)?$/, { message: `${field.label} must be a decimal number` });
31
+ case CaptureFieldType.Enum: {
32
+ const values = [...(field.enumValues ?? [])];
33
+ if (values.length === 0) return z.string();
34
+ return z.enum(values as [string, ...string[]]);
35
+ }
36
+ case CaptureFieldType.Date:
37
+ return z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: `${field.label} must be a date` });
38
+ case CaptureFieldType.Datetime:
39
+ return z.string().min(1, { message: `${field.label} must be a date/time` });
40
+ // string / text / user / reference — plain text.
41
+ default:
42
+ return z.string();
43
+ }
44
+ }
45
+
46
+ function fieldSchema(field: CaptureFieldSpec): z.ZodTypeAny {
47
+ let base: z.ZodTypeAny;
48
+ if (field.type === CaptureFieldType.Object) {
49
+ base = objectSchema(field.fields ?? []);
50
+ } else if (field.type === CaptureFieldType.List) {
51
+ let arr = z.array(objectSchema(field.fields ?? []));
52
+ if (typeof field.min === 'number') arr = arr.min(field.min, { message: `${field.label} needs at least ${field.min} item(s)` });
53
+ if (typeof field.max === 'number') arr = arr.max(field.max, { message: `${field.label} allows at most ${field.max} item(s)` });
54
+ base = arr;
55
+ } else {
56
+ base = scalarSchema(field);
57
+ if (field.required && (field.type === CaptureFieldType.String || field.type === CaptureFieldType.Text || field.type === CaptureFieldType.User || field.type === CaptureFieldType.Reference || field.type === CaptureFieldType.Datetime)) {
58
+ base = (base as z.ZodString).min(1, { message: `${field.label} is required` });
59
+ }
60
+ }
61
+
62
+ if (!field.required) {
63
+ // Optional: `.optional()` makes the object KEY absent-safe (zod v4 does not
64
+ // infer that from a union with z.undefined()); the union additionally
65
+ // accepts the empty-string / null sentinels the inputs emit for "cleared".
66
+ return z.union([base, z.literal(''), z.null()]).optional();
67
+ }
68
+ return base;
69
+ }
70
+
71
+ function objectSchema(fields: readonly CaptureFieldSpec[]): z.ZodObject<z.ZodRawShape> {
72
+ const shape: Record<string, z.ZodTypeAny> = {};
73
+ for (const f of fields) shape[f.key] = fieldSchema(f);
74
+ // Non-strict: unknown keys are ignored (the server is the strict fence).
75
+ return z.object(shape);
76
+ }
77
+
78
+ /**
79
+ * Build a `zod` object schema from a capture schema (flat fields, sections, or
80
+ * a `CaptureSchema`). The result validates the whole values record.
81
+ *
82
+ * @example
83
+ * const schema = fieldSpecToZod([
84
+ * { key: 'title', label: 'Title', type: 'string', required: true },
85
+ * { key: 'tags', label: 'Tags', type: 'list', fields: [{ key: 'name', label: 'Name', type: 'string', required: true }] },
86
+ * ]);
87
+ * schema.safeParse({ title: 'Hi', tags: [{ name: 'a' }] }); // { success: true }
88
+ */
89
+ export function fieldSpecToZod(
90
+ schema: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
91
+ ): z.ZodObject<z.ZodRawShape> {
92
+ return objectSchema(toFieldList(schema));
93
+ }
@@ -0,0 +1,49 @@
1
+ // Pure-logic proofs for the schema-face normalizers. Runs under host-web
2
+ // vitest (this package carries no vitest dep — see tsconfig exclude).
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { CaptureFieldType, groupFieldsBySection, toFieldList, toSections, type CaptureFieldSpec, type CaptureSection } from './field-spec';
6
+
7
+ const fields: CaptureFieldSpec[] = [
8
+ { key: 'a', label: 'A', type: CaptureFieldType.String, section: 'one' },
9
+ { key: 'b', label: 'B', type: CaptureFieldType.Number, section: 'one' },
10
+ { key: 'c', label: 'C', type: CaptureFieldType.Boolean, section: 'two' },
11
+ ];
12
+
13
+ const sections: CaptureSection[] = [
14
+ { key: 's1', title: 'S1', fields: [{ key: 'a', label: 'A', type: CaptureFieldType.String }] },
15
+ { key: 's2', title: 'S2', fields: [{ key: 'b', label: 'B', type: CaptureFieldType.String }] },
16
+ ];
17
+
18
+ describe('toFieldList', () => {
19
+ it('returns a flat field list unchanged', () => {
20
+ expect(toFieldList(fields).map((f) => f.key)).toEqual(['a', 'b', 'c']);
21
+ });
22
+ it('flattens sections to their fields', () => {
23
+ expect(toFieldList(sections).map((f) => f.key)).toEqual(['a', 'b']);
24
+ });
25
+ it('reads the { fields } schema face', () => {
26
+ expect(toFieldList({ fields }).map((f) => f.key)).toEqual(['a', 'b', 'c']);
27
+ });
28
+ it('reads the { sections } schema face', () => {
29
+ expect(toFieldList({ sections }).map((f) => f.key)).toEqual(['a', 'b']);
30
+ });
31
+ });
32
+
33
+ describe('groupFieldsBySection', () => {
34
+ it('splits a flat list by each field section key, preserving order', () => {
35
+ const grouped = groupFieldsBySection(fields);
36
+ expect(grouped.map((s) => s.key)).toEqual(['one', 'two']);
37
+ expect(grouped[0].fields.map((f) => f.key)).toEqual(['a', 'b']);
38
+ expect(grouped[1].fields.map((f) => f.key)).toEqual(['c']);
39
+ });
40
+ });
41
+
42
+ describe('toSections', () => {
43
+ it('passes explicit sections through', () => {
44
+ expect(toSections(sections).map((s) => s.title)).toEqual(['S1', 'S2']);
45
+ });
46
+ it('derives sections from a flat list', () => {
47
+ expect(toSections(fields).map((s) => s.key)).toEqual(['one', 'two']);
48
+ });
49
+ });
@@ -0,0 +1,160 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Guided-capture field spec — the ONE schema shape the capture kit consumes
3
+ //
4
+ // This is the frontend-agnostic superset of app-forge-web's `AppField`. The
5
+ // wire/definition layer (app-forge-api, its renderer's `AppFieldType`) owns 10
6
+ // closed field types; this kit mirrors those 10 verbatim (same string values,
7
+ // so an `AppField` is structurally assignable to `CaptureFieldSpec`) and ADDS
8
+ // two structural composites — `object` and `list` — plus optional `section`
9
+ // grouping. A workbench therefore extends ONE renderer (`<SchemaForm>`) instead
10
+ // of the three parallel schema→form implementations that exist today.
11
+ //
12
+ // Value shape produced/consumed:
13
+ // scalar field → string | number | boolean (per type)
14
+ // object field → Record<string, unknown> (nested record)
15
+ // list field → unknown[] (array of item records)
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+
18
+ /**
19
+ * Closed set of capture field types. The first ten are value-identical to
20
+ * app-forge-web's `AppFieldType` (`string`/`text`/`number`/`decimal`/`boolean`/
21
+ * `date`/`datetime`/`enum`/`reference`/`user`) so definitions authored for the
22
+ * App Platform pass straight through. `object`/`list` are the structural
23
+ * composites this kit adds for nested + repeatable capture.
24
+ *
25
+ * The string VALUES are the contract — keep them identical to app-forge's enum
26
+ * (parity is checked there by `check-app-enum-parity.mjs`). Adding a value here
27
+ * that app-forge lacks is fine (superset); changing an existing value is not.
28
+ */
29
+ export enum CaptureFieldType {
30
+ String = 'string',
31
+ Text = 'text',
32
+ Number = 'number',
33
+ Decimal = 'decimal',
34
+ Boolean = 'boolean',
35
+ Date = 'date',
36
+ Datetime = 'datetime',
37
+ Enum = 'enum',
38
+ Reference = 'reference',
39
+ User = 'user',
40
+ Object = 'object',
41
+ List = 'list',
42
+ }
43
+
44
+ /**
45
+ * A single capture field. Structural superset of app-forge's `AppField`:
46
+ * `key`/`label`/`type`/`required`/`enumValues`/`referenceEntity` carry the
47
+ * exact same meaning, so `AppField[]` can be handed to this kit unchanged. The
48
+ * extra props are additive and all optional.
49
+ *
50
+ * `type` accepts the {@link CaptureFieldType} enum OR its bare string value, so
51
+ * `{ type: 'string' }` (app-forge shape) and `{ type: CaptureFieldType.String }`
52
+ * both type-check.
53
+ */
54
+ export interface CaptureFieldSpec {
55
+ readonly key: string;
56
+ readonly label: string;
57
+ readonly type: CaptureFieldType | `${CaptureFieldType}`;
58
+ readonly required?: boolean;
59
+ /** Helper text rendered under the label. */
60
+ readonly description?: string;
61
+ /** Placeholder for text-like inputs. */
62
+ readonly placeholder?: string;
63
+ /** Only for `enum` — the allowed values. */
64
+ readonly enumValues?: readonly string[];
65
+ /** Only for `reference` — key of the target entity (opaque to this kit). */
66
+ readonly referenceEntity?: string;
67
+ /** Only for `object`/`list` — the nested item shape. */
68
+ readonly fields?: readonly CaptureFieldSpec[];
69
+ /** `number`/`decimal`: numeric bounds. `list`: min/max item count. */
70
+ readonly min?: number;
71
+ readonly max?: number;
72
+ /**
73
+ * Optional section grouping key for FLAT `CaptureFieldSpec[]` schemas. When a
74
+ * schema is expressed as {@link CaptureSection}[] this is ignored (grouping is
75
+ * explicit). See {@link groupFieldsBySection}.
76
+ */
77
+ readonly section?: string;
78
+ }
79
+
80
+ /** A titled group of fields — the explicit sectioned schema shape. */
81
+ export interface CaptureSection {
82
+ readonly key: string;
83
+ readonly title: string;
84
+ readonly description?: string;
85
+ readonly fields: readonly CaptureFieldSpec[];
86
+ }
87
+
88
+ /**
89
+ * A capture schema is EITHER a flat field list OR a list of titled sections.
90
+ * `<SchemaForm>`, `useSchemaValidation`, and `<DocumentPreview>` all accept
91
+ * either face.
92
+ */
93
+ export type CaptureSchema =
94
+ | { readonly fields: readonly CaptureFieldSpec[]; readonly sections?: undefined }
95
+ | { readonly sections: readonly CaptureSection[]; readonly fields?: undefined };
96
+
97
+ /** Structured record value the capture kit reads/writes. */
98
+ export type CaptureValues = Record<string, unknown>;
99
+
100
+ /** True if `x` is the sectioned face of a schema. */
101
+ export function isSectioned(
102
+ x: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
103
+ ): x is { readonly sections: readonly CaptureSection[] } {
104
+ return !Array.isArray(x) && 'sections' in (x as object) && Array.isArray((x as { sections?: unknown }).sections);
105
+ }
106
+
107
+ /**
108
+ * Normalize any accepted schema face to the flat list of TOP-LEVEL fields.
109
+ * (Nested `object`/`list` item fields are NOT flattened — they belong to their
110
+ * parent's value.) Accepts a `CaptureFieldSpec[]`, a `CaptureSection[]`, or a
111
+ * `CaptureSchema`.
112
+ */
113
+ export function toFieldList(
114
+ schema: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
115
+ ): readonly CaptureFieldSpec[] {
116
+ if (Array.isArray(schema)) {
117
+ const arr = schema as readonly (CaptureFieldSpec | CaptureSection)[];
118
+ // A CaptureSection has `title`+`fields`; a CaptureFieldSpec has `type`.
119
+ if (arr.length > 0 && arr.every((e) => 'title' in e && 'fields' in e && !('type' in e))) {
120
+ return (arr as readonly CaptureSection[]).flatMap((s) => s.fields);
121
+ }
122
+ return arr as readonly CaptureFieldSpec[];
123
+ }
124
+ if (isSectioned(schema)) return schema.sections.flatMap((s) => s.fields);
125
+ return (schema as { fields: readonly CaptureFieldSpec[] }).fields;
126
+ }
127
+
128
+ /**
129
+ * Resolve any accepted schema face to `CaptureSection[]`. A flat field list
130
+ * collapses to a single untitled section (or is split by each field's
131
+ * `section` key when present).
132
+ */
133
+ export function toSections(
134
+ schema: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
135
+ fallbackTitle = '',
136
+ ): readonly CaptureSection[] {
137
+ if (Array.isArray(schema) && schema.length > 0 && (schema as readonly object[]).every((e) => 'title' in e && 'fields' in e)) {
138
+ return schema as readonly CaptureSection[];
139
+ }
140
+ if (!Array.isArray(schema) && isSectioned(schema)) return schema.sections;
141
+ return groupFieldsBySection(toFieldList(schema), fallbackTitle);
142
+ }
143
+
144
+ /** Split a flat field list into sections by each field's `section` key. */
145
+ export function groupFieldsBySection(
146
+ fields: readonly CaptureFieldSpec[],
147
+ fallbackTitle = '',
148
+ ): readonly CaptureSection[] {
149
+ const order: string[] = [];
150
+ const byKey = new Map<string, CaptureFieldSpec[]>();
151
+ for (const f of fields) {
152
+ const key = f.section ?? '';
153
+ if (!byKey.has(key)) {
154
+ byKey.set(key, []);
155
+ order.push(key);
156
+ }
157
+ byKey.get(key)!.push(f);
158
+ }
159
+ return order.map((key) => ({ key: key || '_', title: key || fallbackTitle, fields: byKey.get(key)! }));
160
+ }
@@ -0,0 +1,43 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // @xemahq/ui-kernel — guided-capture UX kit (B5)
3
+ //
4
+ // Headless engines + presentation-thin primitives for schema-driven, guided
5
+ // data capture: a wizard state machine, live client-side validation, AI/context
6
+ // pre-fill, a single consolidated schema→form renderer, and a review-step
7
+ // document preview. Consolidates the three parallel schema→form implementations
8
+ // so a workbench extends ONE renderer.
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ // ── field-spec model (aligned superset of app-forge's AppFieldType) ──
12
+ export {
13
+ CaptureFieldType,
14
+ isSectioned,
15
+ toFieldList,
16
+ toSections,
17
+ groupFieldsBySection,
18
+ type CaptureFieldSpec,
19
+ type CaptureSection,
20
+ type CaptureSchema,
21
+ type CaptureValues,
22
+ } from './field-spec';
23
+
24
+ // ── schema → zod adapter ──
25
+ export { fieldSpecToZod, isEmptyValue } from './field-spec-to-zod';
26
+
27
+ // ── validation + "missing info" ──
28
+ export { useSchemaValidation, type SchemaValidationResult } from './use-schema-validation';
29
+
30
+ // ── pre-fill ──
31
+ export { usePrefill, type UsePrefillOptions, type UsePrefillResult } from './use-prefill';
32
+
33
+ // ── wizard engine + shell ──
34
+ export { useWizard, type WizardApi, type WizardStepDef, type UseWizardOptions } from './use-wizard';
35
+ export { Wizard, WizardStep, type WizardProps, type WizardStepProps } from './wizard';
36
+
37
+ // ── consolidated schema form ──
38
+ export { SchemaForm, type SchemaFormProps } from './schema-form';
39
+ export { SchemaFieldInput, type SchemaFieldInputProps, type RenderReference } from './schema-field-input';
40
+ export { asString, formatScalar } from './value-format';
41
+
42
+ // ── review-step document preview ──
43
+ export { DocumentPreview, type DocumentPreviewProps } from './document-preview';
@@ -0,0 +1,186 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // SchemaFieldInput — one typed input per CaptureFieldType
3
+ //
4
+ // Mirrors app-forge-web's `FieldInput` (the 10 typed inputs) using ui-kernel
5
+ // primitives, and ADDS the `object` (nested group) and `list` (repeatable
6
+ // items) composites. `reference` is source-agnostic here: it defaults to a text
7
+ // input, but a caller can inject a picker via `renderReference` (that is the
8
+ // seam app-forge uses to plug its record-backed `ReferenceSelect`).
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+ import { Plus, Trash2 } from 'lucide-react';
11
+ import { type ReactNode } from 'react';
12
+
13
+ import { cn } from '../cn';
14
+ import { Button } from '../primitives/button';
15
+ import { Checkbox } from '../primitives/checkbox';
16
+ import { Input } from '../primitives/input';
17
+ import { Label } from '../primitives/label';
18
+ import { Textarea } from '../primitives/textarea';
19
+
20
+ import { CaptureFieldType, type CaptureFieldSpec, type CaptureValues } from './field-spec';
21
+ import { asString } from './value-format';
22
+
23
+ const SELECT_CLASS =
24
+ 'mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-body-1 text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
25
+
26
+ /** Optional override for the `reference` field type (record-backed picker). */
27
+ export type RenderReference = (args: {
28
+ readonly field: CaptureFieldSpec;
29
+ readonly value: unknown;
30
+ readonly onChange: (value: unknown) => void;
31
+ readonly disabled?: boolean;
32
+ readonly inputId: string;
33
+ }) => ReactNode;
34
+
35
+ export interface SchemaFieldInputProps {
36
+ readonly field: CaptureFieldSpec;
37
+ readonly value: unknown;
38
+ readonly onChange: (value: unknown) => void;
39
+ readonly disabled?: boolean;
40
+ /** Dot-path prefix for nested/list item fields (labels/ids stay unique). */
41
+ readonly path: string;
42
+ /** Per-field errors keyed by dot-path (from `useSchemaValidation` or server). */
43
+ readonly errors?: Readonly<Record<string, string>>;
44
+ readonly renderReference?: RenderReference;
45
+ }
46
+
47
+ export function SchemaFieldInput(props: SchemaFieldInputProps) {
48
+ const { field, value, onChange, disabled, path, renderReference } = props;
49
+ const inputId = `field-${path}`;
50
+
51
+ switch (field.type) {
52
+ case CaptureFieldType.Text:
53
+ return <Textarea id={inputId} value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} rows={4} placeholder={field.placeholder} />;
54
+ case CaptureFieldType.Number:
55
+ return (
56
+ <Input
57
+ id={inputId}
58
+ type="number"
59
+ value={asString(value)}
60
+ onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
61
+ disabled={disabled}
62
+ placeholder={field.placeholder}
63
+ />
64
+ );
65
+ case CaptureFieldType.Decimal:
66
+ // NUMERIC carried verbatim as text — never Number()-coerced (precision).
67
+ return <Input id={inputId} type="text" inputMode="decimal" value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} placeholder={field.placeholder ?? '0.00'} />;
68
+ case CaptureFieldType.User:
69
+ return <Input id={inputId} value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} placeholder={field.placeholder ?? 'subject id'} />;
70
+ case CaptureFieldType.Boolean:
71
+ return (
72
+ <div className="mt-1">
73
+ <Checkbox id={inputId} checked={value === true} onCheckedChange={(checked) => onChange(checked === true)} disabled={disabled} />
74
+ </div>
75
+ );
76
+ case CaptureFieldType.Date:
77
+ return <Input id={inputId} type="date" value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} />;
78
+ case CaptureFieldType.Datetime:
79
+ return <Input id={inputId} type="datetime-local" value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} />;
80
+ case CaptureFieldType.Enum:
81
+ return (
82
+ <select id={inputId} className={SELECT_CLASS} value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled}>
83
+ <option value="">— select —</option>
84
+ {(field.enumValues ?? []).map((v) => (
85
+ <option key={v} value={v}>
86
+ {v}
87
+ </option>
88
+ ))}
89
+ </select>
90
+ );
91
+ case CaptureFieldType.Reference:
92
+ return renderReference ? (
93
+ <>{renderReference({ field, value, onChange, disabled, inputId })}</>
94
+ ) : (
95
+ <Input id={inputId} value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} placeholder={field.placeholder ?? 'reference id'} />
96
+ );
97
+ case CaptureFieldType.Object:
98
+ return <ObjectInput {...props} />;
99
+ case CaptureFieldType.List:
100
+ return <ListInput {...props} />;
101
+ // string (and any unknown) — plain single-line input.
102
+ default:
103
+ return <Input id={inputId} value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} placeholder={field.placeholder} />;
104
+ }
105
+ }
106
+
107
+ /** Renders one label + input + error block for a nested field. */
108
+ function NestedField(props: SchemaFieldInputProps) {
109
+ const { field, path, errors } = props;
110
+ const error = errors?.[path];
111
+ return (
112
+ <div>
113
+ <Label htmlFor={`field-${path}`}>
114
+ {field.label}
115
+ {field.required ? <span className="text-destructive"> *</span> : null}
116
+ </Label>
117
+ {field.description ? <p className="mb-1 text-caption text-ink-3">{field.description}</p> : null}
118
+ <SchemaFieldInput {...props} />
119
+ {error ? <p className="mt-1 text-caption text-destructive">{error}</p> : null}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function ObjectInput({ field, value, onChange, disabled, path, errors, renderReference }: SchemaFieldInputProps) {
125
+ const record = (value && typeof value === 'object' && !Array.isArray(value) ? value : {}) as CaptureValues;
126
+ const fields = field.fields ?? [];
127
+ return (
128
+ <div className="mt-1 space-y-3 rounded-lg border border-rule bg-paper-elev/40 p-3">
129
+ {fields.map((child) => (
130
+ <NestedField
131
+ key={child.key}
132
+ field={child}
133
+ value={record[child.key]}
134
+ onChange={(v) => onChange({ ...record, [child.key]: v })}
135
+ disabled={disabled}
136
+ path={`${path}.${child.key}`}
137
+ errors={errors}
138
+ renderReference={renderReference}
139
+ />
140
+ ))}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function ListInput({ field, value, onChange, disabled, path, errors, renderReference }: SchemaFieldInputProps) {
146
+ const items = (Array.isArray(value) ? value : []) as CaptureValues[];
147
+ const fields = field.fields ?? [];
148
+ const atMax = typeof field.max === 'number' && items.length >= field.max;
149
+
150
+ const setItem = (index: number, next: CaptureValues) => onChange(items.map((it, i) => (i === index ? next : it)));
151
+ const removeItem = (index: number) => onChange(items.filter((_, i) => i !== index));
152
+ const addItem = () => onChange([...items, {}]);
153
+
154
+ return (
155
+ <div className="mt-1 space-y-2">
156
+ {items.map((item, index) => (
157
+ <div key={index} className="relative space-y-3 rounded-lg border border-rule bg-paper-elev/40 p-3">
158
+ <div className="flex items-center justify-between">
159
+ <span className="text-caption font-medium text-ink-3">
160
+ {field.label} #{index + 1}
161
+ </span>
162
+ <Button type="button" variant="ghost" size="icon" onClick={() => removeItem(index)} disabled={disabled} aria-label={`Remove ${field.label} ${index + 1}`}>
163
+ <Trash2 className="h-4 w-4" />
164
+ </Button>
165
+ </div>
166
+ {fields.map((child) => (
167
+ <NestedField
168
+ key={child.key}
169
+ field={child}
170
+ value={item[child.key]}
171
+ onChange={(v) => setItem(index, { ...item, [child.key]: v })}
172
+ disabled={disabled}
173
+ path={`${path}.${index}.${child.key}`}
174
+ errors={errors}
175
+ renderReference={renderReference}
176
+ />
177
+ ))}
178
+ </div>
179
+ ))}
180
+ <Button type="button" variant="outline" size="sm" onClick={addItem} disabled={disabled || atMax} className={cn(items.length === 0 && 'w-full')}>
181
+ <Plus className="mr-1.5 h-3.5 w-3.5" />
182
+ Add {field.label}
183
+ </Button>
184
+ </div>
185
+ );
186
+ }