diff --git a/README.md b/README.md index 5ce18cf..1aa8dfc 100644 --- a/README.md +++ b/README.md @@ -122,18 +122,12 @@ function useTeamFormTree(tree: GroupNode, options?: UseFormTreeOptions) } ``` -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' }), @@ -145,11 +139,12 @@ const schema = z.object({ .meta({ title: 'Address' }), }) -type Shape = FormShapeOf const tree = zodToTree(schema) -const rules = (r: TypedRuleRegistrar): 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 }) => ( <> @@ -158,50 +153,51 @@ const rules = (r: TypedRuleRegistrar): void => { - )) - r.group('address', ({ parts, children }) => ( + ), + address: ({ parts, children }) => (
{children}
- )) -} + ), +} satisfies InterceptMap export function ProfileForm() { - const { form, SchemaFields, submit } = useFormTree(tree) - const intercept = useRenderNodeRules(form, rules) + const { SchemaFields, submit } = useTeamFormTree(tree) return (
console.log(data))}> - + ) } ``` -**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 (``, ``, `{children}` for -a group's descendants) or re-enter the whole node with ``. 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 ``. If the +exception is one part, a parts object is the same move as +`` — `{ '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 (`` / ``). 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 diff --git a/examples/basic-react/src/App.tsx b/examples/basic-react/src/App.tsx index edd488e..092fc72 100644 --- a/examples/basic-react/src/App.tsx +++ b/examples/basic-react/src/App.tsx @@ -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 }, { @@ -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, }, { @@ -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() { diff --git a/examples/basic-react/src/App_07_React+Arrays.tsx b/examples/basic-react/src/App_07_React+Arrays.tsx index e92c710..bffa000 100644 --- a/examples/basic-react/src/App_07_React+Arrays.tsx +++ b/examples/basic-react/src/App_07_React+Arrays.tsx @@ -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' @@ -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 ``. Add/remove state is lifted; the template composes parts. const schema: JSONSchema = { type: 'object', @@ -20,7 +29,6 @@ const schema: JSONSchema = { title: 'Full Name', description: 'Enter your full name', }, - // Multiselect — primitive array with enum → - ) - }} - /> - - + ) } @@ -150,49 +123,25 @@ function CityNote({ Default }: FieldProps) { ) } -const customizeRules = (r: TypedRuleRegistrar): void => { - r.field('name', RowName) - r.group('address', CardGroup) - r.field('address.street', StreetInput) - r.field('address.city', CityNote) - - // INLINE handler → props inferred as FieldProps (no annotation), - // because `r` is annotated `TypedRuleRegistrar` on this builder above. - r.field('plan', ({ value, Default }) => { - // Hover `value`: 'free' | 'pro' | 'enterprise' | undefined — the schema enum, - // plus `undefined` because live values await a form-state adapter (ADR 047 §7). - void value - return - }) - - // ── Guardrails: each is a COMPILE ERROR. ─────────────────────────────────── - // @ts-expect-error 'nope' is not a field path - r.field('nope', () => null) - // @ts-expect-error 'address' is a GROUP, not a field — the error NAMES the fix - // ("use r.group(), not r.field()"), not just "not assignable to a union" (bd q8v). - r.field('address', () => null) - // @ts-expect-error 'address.city' is a FIELD, not a group — hints "use r.field()" - r.group('address.city', () => null) +const customizeIntercept = { + name: RowName, + address: CardGroup, + 'address.street': { control: StreetControl }, + 'address.city': CityNote, } function LiveCustomizedForm() { const tree = useMemo(() => jsonSchemaToTree(schema), []) const validator = useMemo(() => createAjvValidator(schema), []) - const { form, SchemaFields: Fields } = useFormTree(tree) - // Type off `form` — the tree that ACTUALLY renders — not the pre-present input - // (bd bh7.8). With no `overrideWidgets` here the two brands are identical, but - // typing off `form` is the desync-proof habit: if you later add - // `resolvePresentation: overrideWidgets(map)`, the narrowed control re-narrows to - // match the override with no other change. `useInterceptRules` reads that brand - // to type the rules and bakes in the stable-resolver memo (ADR 048). - const intercept = useInterceptRules(form, customizeRules) - // Recipe-owned validation: produce errors here, inject via the provider. + const { form, SchemaFields: Fields } = useFormTree(tree, { + defaults: nativeFieldDefaults, + }) const { validation, submit, revalidate } = useNativeValidator(form, validator) const [data, setData] = useState | null>(null) return (
setData(d))} onInput={revalidate}> - +