Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 29 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,12 @@ function useTeamFormTree<S>(tree: GroupNode<S>, options?: UseFormTreeOptions<S>)
}
```

Path intercepts still use `useRenderNodeRules` (sugar over `intercept`): each
`if` in an intercept mega-switch becomes one registrar call, and
**specificity replaces order**.
Path intercepts are the exception layer — pass them to `SchemaFields`:

```tsx
import { z } from 'zod'
import { zodToTree, type FormShapeOf } from '@formframe/input-zod'
import {
useFormTree,
useRenderNodeRules,
type TypedRuleRegistrar,
} from '@formframe/renderer-react'
import { zodToTree } from '@formframe/input-zod'
import { type InterceptMap } from '@formframe/renderer-react'

const schema = z.object({
email: z.string().email().meta({ title: 'Email' }),
Expand All @@ -145,11 +139,12 @@ const schema = z.object({
.meta({ title: 'Address' }),
})

type Shape = FormShapeOf<typeof schema>
const tree = zodToTree(schema)

const rules = (r: TypedRuleRegistrar<Shape>): void => {
r.field('email', ({ parts }) => (
// Hoist handlers that call hooks. String keys + annotated handler props are fine
// until a FormShape-typed map lands (#87).
const customizeIntercept = {
email: ({ parts }) => (
<>
<span>
<parts.Label />
Expand All @@ -158,50 +153,51 @@ const rules = (r: TypedRuleRegistrar<Shape>): void => {
<parts.Control />
<parts.Errors />
</>
))
r.group('address', ({ parts, children }) => (
),
address: ({ parts, children }) => (
<section className="address-grid">
<parts.Label />
{children}
</section>
))
}
),
} satisfies InterceptMap

export function ProfileForm() {
const { form, SchemaFields, submit } = useFormTree(tree)
const intercept = useRenderNodeRules(form, rules)
const { SchemaFields, submit } = useTeamFormTree(tree)

return (
<form onSubmit={submit((data) => console.log(data))}>
<SchemaFields intercept={intercept} />
<SchemaFields intercept={customizeIntercept} />
<button type="submit">Save profile</button>
</form>
)
}
```

**From a mega-switch:** `if (node.isField && node.path === 'email')` becomes
`r.field('email', …)`; `if (node.isGroup && node.path === 'address')` becomes
`r.group('address', …)`. Unmatched nodes keep the default, so there is no
fallback branch. Register in any order. One winning rule per node, most
specific first: exact path, then `where`, then `control(kind)`, then
`allFields` / `allGroups` / `allArrays`, then `default`. Recipes use the same
registrar for a blanket widget swap (`r.control('input', InputControl)`).
Use a **path map** (`{ email: Handler, 'address.street': { control: X } }`) or a
**bag** (`{ paths: { … }, where: [[pred, Handler], …] }`) for path-specific
customization. A hand-written **function**
`(node, { Default, Children }) => …` is the floor — the map and bag lower to
it. One winning handler per node; **specificity replaces order**: exact path,
then `where`, then defaults. Unmatched nodes keep the team defaults.

Handlers place parts (`<parts.Label />`, `<parts.Control />`, `{children}` for
a group's descendants) or re-enter the whole node with `<Default />`. Type the
hook off `form` from `useFormTree`, not the input tree. JSON Schema literals
need `as const` for path types. Hoist handlers that call hooks.
a group's descendants) or re-enter the whole node with `<Default />`. If the
exception is one part, a parts object is the same move as
`<Default parts>` — `{ 'address.street': { control: StreetControl } }` — not a
handler that re-places Label/Errors. Hoist handlers that call hooks. Recipes
pass **defaults** (error inject, form-lib wiring) through
`useFormTree({ defaults })` — not a winning intercept.

`intercept` is still the floor (`<Default of={node} />` / `<Children of={node} />`).
The numbered gallery in [`examples/basic-react`](./examples/basic-react) walks
up to that floor on purpose (App_08 is the mega-switch). Copy a recipe, or
up to the function floor on purpose ([App_08](./examples/basic-react/src/App_08_React+Overrides.tsx)
is the hand-written intercept). Copy a recipe, or
[App_16](./examples/basic-react/src/App_16_React+Customize.tsx) /
[App_17](./examples/basic-react/src/App_17_React+CustomizeZod.tsx), for the
rules path.
path-map / bag path.

`Default` and `Children` are also exported from
`@formframe/renderer-react` for authored layouts outside a rule handler.
`@formframe/renderer-react` for authored layouts outside an intercept handler.

## Choose a schema input

Expand Down
12 changes: 8 additions & 4 deletions examples/basic-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ const examples = [
{ id: '06', name: 'React + useFormTree Hook', component: App06 },
{ id: '06B', name: 'React + SchemaFields (ADR 010)', component: App06B },
{ id: '07', name: 'React + Array Support', component: App07 },
{ id: '08', name: 'React + Overrides (ADR 010)', component: App08 },
{
id: '08',
name: 'React + intercept function floor (ADR 051)',
component: App08,
},
{ id: '09', name: 'React + Validation (recipe-owned)', component: App09 },
{ id: '10', name: 'React + Schema $ref/$defs', component: App10 },
{
Expand Down Expand Up @@ -67,12 +71,12 @@ const examples = [
},
{
id: '16',
name: 'React + interceptRules (ADR 047)',
name: 'React + defaults then intercept map (ADR 051)',
component: App16,
},
{
id: '17',
name: 'React + interceptRules over Zod (ADR 047 / ADR 008)',
name: 'React + defaults then intercept map · Zod (ADR 051)',
component: App17,
},
{
Expand Down Expand Up @@ -104,7 +108,7 @@ function galleryLabel(example: { id: string; name: string }): string {
: example.name
}

/** Landing example — the headline interceptRules demo (ADR 047/048). */
/** Landing example — the headline defaults-then-intercept demo (ADR 051). */
const DEFAULT_EXAMPLE_ID = '16'

function App() {
Expand Down
82 changes: 70 additions & 12 deletions examples/basic-react/src/App_07_React+Arrays.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useState } from 'react'
import { useFormTree } from '@formframe/renderer-react'
import {
mergeDefaults,
nativeDefaults,
useFormTree,
type ReactPartialDefaults,
} from '@formframe/renderer-react'
import { jsonSchemaToRuntimeTree } from '@formframe/input-jsonschema'
import type { JSONSchema } from '@formframe/input-jsonschema'

Expand All @@ -11,6 +16,10 @@ import type { JSONSchema } from '@formframe/input-jsonschema'
// AND paths stay dense (ADR 018) — remove the first of two and the survivor
// re-paths from `…1` to `…0` in place, so submission is a contiguous array, never
// a sparse one with a leading hole.
//
// ADR 051: arrays arrange like groups — a custom `defaults.array.root` places
// Label + `{children}` + AddButton (item chrome via `defaults.arrayItem`) without
// wrapping `<Default />`. Add/remove state is lifted; the template composes parts.

const schema: JSONSchema = {
type: 'object',
Expand All @@ -20,7 +29,6 @@ const schema: JSONSchema = {
title: 'Full Name',
description: 'Enter your full name',
},
// Multiselect — primitive array with enum → <select multiple>
skills: {
type: 'array',
title: 'Skills',
Expand All @@ -32,14 +40,12 @@ const schema: JSONSchema = {
enum: ['JavaScript', 'TypeScript', 'React', 'Node.js', 'Python', 'Go'],
},
},
// Dynamic array of strings → a text input per item, with add/remove
hobbies: {
type: 'array',
title: 'Hobbies',
description: 'Add and remove hobbies; type into one, then add another',
items: { type: 'string', title: 'Hobby' },
},
// Dynamic array of objects → a sub-form per item, with add/remove
addresses: {
type: 'array',
title: 'Addresses',
Expand Down Expand Up @@ -68,8 +74,58 @@ const schema: JSONSchema = {
}
const tree = jsonSchemaToRuntimeTree(schema)

const galleryArrayDefaults: ReactPartialDefaults = {
array: {
...nativeDefaults.array,
root: ({ node, children }) => (
<section
style={{
border: '1px solid #ccc',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}
>
{node.parts.label?.Default()}
{node.parts.description?.Default()}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
margin: '8px 0',
}}
>
{children}
</div>
{node.parts.addButton.Default()}
</section>
),
},
arrayItem: {
...nativeDefaults.arrayItem,
root: ({ node, children }) => (
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'flex-start',
padding: 8,
background: '#fafafa',
borderRadius: 4,
}}
>
<div style={{ flex: 1 }}>{children}</div>
{node.parts.removeButton.Default()}
</div>
),
},
}

function App() {
const { form, SchemaFields } = useFormTree(tree)
const { form, SchemaFields } = useFormTree(tree, {
defaults: mergeDefaults(nativeDefaults, galleryArrayDefaults),
})
const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(
null
)
Expand All @@ -78,13 +134,15 @@ function App() {
<div>
<h1>Dynamic arrays (ADR 015)</h1>
<p style={{ color: '#555' }}>
Add/remove items folded by the continuation engine. The trick: each item
has a <strong>stable React key</strong> (its identity) decoupled from
its <strong>path</strong> (its position), so adding or removing a
sibling updates the list <em>in place</em> — every other item keeps its
typed value with no remount, while paths stay <em>dense</em>. Type into
a few fields, remove the first item, then submit: the survivors re-path
to a contiguous array — no holes.
Add/remove items folded by the continuation engine. Each item has a{' '}
<strong>stable React key</strong> (its identity) decoupled from its{' '}
<strong>path</strong> (its position), so adding or removing a sibling
updates the list <em>in place</em> — every other item keeps its typed
value with no remount, while paths stay <em>dense</em>. A custom{' '}
<code>defaults.array.root</code> arranges label, items, and add button
like a group — no <code>&lt;Default /&gt;</code> wrapper. Type into a
few fields, remove the first item, then submit: the survivors re-path to
a contiguous array — no holes.
</p>

<form onSubmit={form.submit(setSubmitted)}>
Expand Down
21 changes: 13 additions & 8 deletions examples/basic-react/src/App_08_React+Overrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { jsonSchemaToRuntimeTree } from '@formframe/input-jsonschema'
import type { JSONSchema } from '@formframe/input-jsonschema'
import { SchemaFields } from '@formframe/renderer-react'

// The real continuation engine (ADR 010) — the typed successor to the spike.
// One primitive (`renderNode`), two granularities (node / part), three moves
// (hijack, swap-parts, place-yourself) — all the way down, fully typed.
// The intercept function floor (ADR 010 / ADR 051) — hand-written
// `(node, { Default, Children }) => …`. Path maps and `{ paths, where }` bags
// (examples 16–17) lower to this shape. Two granularities (node / part), three
// moves (hijack, swap-parts, place-yourself) — all the way down, fully typed.
// Each branch narrows on `node.isField`/`isGroup`/`widget` before reaching
// variant-specific members (ADR 012); no `any`.

Expand Down Expand Up @@ -88,8 +89,12 @@ export default function App() {

return (
<div>
<h1>Recursive continuation renderer (ADR 010)</h1>
<p>One primitive, two granularities, three moves — all the way down.</p>
<h1>Intercept function floor (ADR 010 / ADR 051)</h1>
<p>
One hand-written <code>intercept</code> function, two granularities,
three moves — all the way down. Examples 16–17 pass a path map or bag;
those lower to this function shape.
</p>

<Section title="1. Default whole form">
<SchemaFields form={form} />
Expand Down Expand Up @@ -201,7 +206,7 @@ export default function App() {
</form>
</Section>

<Section title="4. Recursion within recursion — root layout + a scoped renderNode subtree">
<Section title="4. Recursion within recursion — root layout + a scoped intercept subtree">
<form>
<SchemaFields form={form}>
{(root, { Default }) => {
Expand All @@ -212,7 +217,7 @@ export default function App() {
<p style={{ color: '#666' }}>
Hand-authored root; <code>theme</code> rendered dynamically
by name; the <code>address</code> subtree carries its own
scoped <code>renderNode</code>.
scoped <code>intercept</code>.
</p>

{/* static keyed children */}
Expand All @@ -222,7 +227,7 @@ export default function App() {
{/* dynamic child by relative path */}
<Default of={theme} />

{/* render address default, but inject a renderNode scoped to ITS subtree */}
{/* render address default, but inject an intercept scoped to ITS subtree */}
{address.isGroup && (
<Default
of={address}
Expand Down
Loading