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
38 changes: 38 additions & 0 deletions .changeset/rjsf-6-peer-range-and-theme-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'acture-forms-rjsf': minor
---

Accept `@rjsf` 6.x, and make the RJSF theme injectable.

The `@rjsf/core` / `@rjsf/utils` / `@rjsf/validator-ajv8` peer ranges move from
`^5.20.0` to `^5.20.0 || ^6.0.0`. `@rjsf/shadcn` is published on the 6.x line
only, so the old range made the shadcn theme — the one every frontend in this
ecosystem wants — impossible to install alongside this adapter. The failure was
an npm peer conflict at install time, i.e. before a line of form code got
written. No adapter code was needed to span the two majors: `@rjsf/core`'s
default export and `FormProps`, `@rjsf/validator-ajv8`'s default export, and the
`schema` / `formData` / `validator` / `liveValidate` / `onSubmit` / children
props are shape-identical on both.

`<RjsfForm />` gains an optional **`form`** prop taking any RJSF theme's
`<Form />` (`ComponentType<FormProps>` — the type every theme's default export
already has), defaulting to the unstyled `@rjsf/core` form:

```tsx
import ShadcnForm from '@rjsf/shadcn';
<RjsfForm form={ShadcnForm} command={cmd} onSubmit={run} onCancel={close} />;
```

Before this, the README told you to "pass your own `Form` from the themed
package" and there was no prop that accepted one. acture still bundles no UI kit
(hard-don't #8) — the theme is the host's, injected.

Both halves of the widened range are checked on every PR: the main CI job
installs and tests 6.x, and a second job installs its own plain 5.x tree and
typechecks + tests the adapter against it.

The `form` value must be referentially stable (bind a theme at module level) —
React reconciles by element type, so an inline `withTheme(...)` remounts the
form and discards in-progress input. Documented on the prop and in the README.

Also exported: the `RjsfFormComponent` type.
17 changes: 17 additions & 0 deletions .claude/skills/acture-palette-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ function deriveKind(record: CommandRecord): "atomic" | "handoff" {
- **autoform** — Zod-native; lighter; fits the recommended Zod-first authoring path.
- **rjsf** — JSON-Schema-native; battle-tested; larger bundle but more themes.

`RjsfForm` takes the theme as an optional **`form`** prop — any RJSF theme's
default export (`ComponentType<FormProps>`), defaulting to the unstyled
`@rjsf/core` one. That is hard-don't #8's slot API, not a bundled UI kit: bind
the theme once at the call site to get a `PaletteFormAdapter`.

```tsx
import ShadcnForm from '@rjsf/shadcn';
const ShadcnRjsfForm: PaletteFormAdapter = (p) => <RjsfForm {...p} form={ShadcnForm} />;
```

Bind the theme at **module level** — React reconciles by element type, so an
inline `withTheme(...)` remounts the form and wipes what the user typed.

Peer range is `@rjsf` `^5.20.0 || ^6.0.0`, and CI installs and tests both majors
(the `rjsf5` job runs a second, plain 5.x install). **Prefer 6.x** —
`@rjsf/shadcn` is published on the 6.x line only.

Acture's core does not bundle a form library. Per redesign-takeaways §2.3.

## The don't-do list (research-2 §9.5)
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,50 @@ jobs:
run: |
ls -la /tmp/acture-*.tgz
tar -tzf /tmp/acture-*.tgz

# The published `acture-forms-rjsf` peer range is `^5.20.0 || ^6.0.0`. The job
# above installs and tests the 6.x half; this one installs and tests the 5.x
# half, so both ends of the promise are checked on every PR.
#
# It is a second INSTALL, not a second alias tree in one install — which is the
# only thing pnpm's peers-are-matched-by-name behaviour actually rules out.
# `scripts/pin-rjsf-5x.mjs` rewrites the three `@rjsf` devDependencies to the
# 5.x clause READ OFF the declared peer range, drops the 6.x-only
# `@rjsf/shadcn` and its smoke test, and no-ops if the range ever narrows to
# 6-only. Without this job a value that is legal on 6.x but not on 5.x — e.g.
# `liveValidate="onChange"`, widened in 6.x — ships green.
rjsf5:
name: forms-rjsf against @rjsf 5.x
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Pin the forms-rjsf dev tree to the 5.x half of its peer range
run: node scripts/pin-rjsf-5x.mjs

# The lockfile describes the 6.x tree, so this install deliberately
# resolves fresh. Nothing is committed from this job.
- name: Install
run: pnpm install --no-frozen-lockfile

- name: Assert a real 5.x tree resolved
run: node scripts/pin-rjsf-5x.mjs --verify

# forms-rjsf consumes `acture` types out of its dist/.
- name: Build acture
run: pnpm --filter acture build

- name: Typecheck forms-rjsf against 5.x
run: pnpm --filter acture-forms-rjsf typecheck

- name: Test forms-rjsf against 5.x
run: pnpm --filter acture-forms-rjsf test
40 changes: 39 additions & 1 deletion packages/forms-rjsf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ For Zod-first authoring with a leaner runtime, prefer [`acture-forms-autoform`](
pnpm add acture-forms-rjsf @rjsf/core @rjsf/utils @rjsf/validator-ajv8 react
```

Peers on `@rjsf` **`^5.20.0 || ^6.0.0`**. Every API this adapter touches is
shape-identical across the two majors, so the same code serves both, and **both
halves run in CI**: the main job installs and tests 6.x, and a second job
(`rjsf5`) installs its own plain 5.x tree and typechecks + tests the adapter
against it. **Prefer 6.x** — `@rjsf/shadcn`, the theme most hosts want, is
published on the 6.x line only, so the shadcn smoke test is 6.x-only.

## Use as a palette form adapter

```tsx
Expand All @@ -28,7 +35,38 @@ import { RjsfForm } from 'acture-forms-rjsf';

## Theming

The default render is rjsf's bare bones. To use a theme, wrap `RjsfForm` and pass your own `Form` from the themed package (`@rjsf/mui`, `@rjsf/chakra-ui`, etc.). The acture-side bridge is identical.
The default render is rjsf's bare bones. Pass any RJSF theme's `<Form />` via the
`form` prop — acture never bundles a UI kit, so the design system is your choice:

```tsx
import ShadcnForm from '@rjsf/shadcn';
import { RjsfForm } from 'acture-forms-rjsf';

<RjsfForm form={ShadcnForm} command={cmd} onSubmit={run} onCancel={close} />;
```

To hand a themed form to the palette, bind the theme once:

```tsx
import type { PaletteFormAdapter } from 'acture-palette-react';

const ShadcnRjsfForm: PaletteFormAdapter = (props) => (
<RjsfForm {...props} form={ShadcnForm} />
);

<CommandPalette registry={registry} context={ctx} formAdapter={ShadcnRjsfForm} />;
```

Every RJSF theme's default export has the same type (`ComponentType<FormProps>`),
so `@rjsf/mui`, `@rjsf/chakra-ui`, `@rjsf/antd`, … all drop in the same way. The
acture-side bridge is identical.

> **Keep the `form` value referentially stable.** React reconciles by element
> type, so building the theme inline — `form={withTheme(myTheme)}` in JSX, or an
> un-hoisted adapter arrow — yields a new component type on every render: the
> form remounts and whatever the user had typed is silently reset to `defaults`.
> Both snippets above are safe because `ShadcnForm` and `ShadcnRjsfForm` are
> module-level bindings. Hoist yours the same way.

## See also

Expand Down
15 changes: 8 additions & 7 deletions packages/forms-rjsf/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "acture-forms-rjsf",
"version": "1.0.0",
"description": "JSON-Schema-native form adapter for acture-palette-react built on react-jsonschema-form (@rjsf/core).",
"description": "JSON-Schema-native form adapter for acture-palette-react built on react-jsonschema-form (@rjsf/core 5.x or 6.x), with a pluggable theme.",
"license": "Apache-2.0",
"author": "Thor Whalen",
"repository": {
Expand Down Expand Up @@ -36,16 +36,17 @@
"clean": "rm -rf dist *.tsbuildinfo"
},
"peerDependencies": {
"@rjsf/core": "^5.20.0",
"@rjsf/utils": "^5.20.0",
"@rjsf/validator-ajv8": "^5.20.0",
"@rjsf/core": "^5.20.0 || ^6.0.0",
"@rjsf/utils": "^5.20.0 || ^6.0.0",
"@rjsf/validator-ajv8": "^5.20.0 || ^6.0.0",
"acture": "^1.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@rjsf/core": "^5.24.0",
"@rjsf/utils": "^5.24.0",
"@rjsf/validator-ajv8": "^5.24.0",
"@rjsf/core": "^6.8.0",
"@rjsf/shadcn": "^6.8.0",
"@rjsf/utils": "^6.8.0",
"@rjsf/validator-ajv8": "^6.8.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
7 changes: 6 additions & 1 deletion packages/forms-rjsf/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
*
* For Zod-first authoring with a leaner runtime, prefer
* [`acture-forms-autoform`](../forms-autoform).
*
* Peers on `@rjsf` `^5.20.0 || ^6.0.0`; prefer **6.x**, which is what CI tests
* and the only line `@rjsf/shadcn` is published on. The `form` prop takes any
* RJSF theme's `<Form />` — `@rjsf/shadcn`, `@rjsf/mui`, … — so the host picks
* the UI kit and this package never bundles one.
*/

export { RjsfForm } from './rjsf-form.js';
export type { RjsfFormProps } from './rjsf-form.js';
export type { RjsfFormProps, RjsfFormComponent } from './rjsf-form.js';
14 changes: 14 additions & 0 deletions packages/forms-rjsf/src/rjsf-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,18 @@ describe('<RjsfForm />', () => {
fireEvent.keyDown(container.querySelector('[data-acture-rjsf]')!, { key: 'Escape' });
expect(onCancel).toHaveBeenCalled();
});

it('falls back to the @rjsf/core form when no theme is injected', () => {
const cmd = defineCommand({
id: 'app.t.add',
title: 'Add',
params: z.object({ label: z.string() }),
execute: (p) => ok(p),
});
const { container } = render(
<RjsfForm command={cmd} onSubmit={() => {}} onCancel={() => {}} />,
);
// `form-control` is the @rjsf/core BaseInputTemplate's class.
expect(container.querySelector('input.form-control')).toBeTruthy();
});
});
43 changes: 40 additions & 3 deletions packages/forms-rjsf/src/rjsf-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,67 @@
* `<RjsfForm />` — render a CommandRecord's params schema using
* react-jsonschema-form. Matches the `PaletteFormAdapter` shape
* expected by `acture-palette-react`.
*
* The `<Form />` implementation is injectable (`form` prop) so the host can
* render through any RJSF theme — `@rjsf/shadcn`, `@rjsf/mui`, … — without
* this package ever depending on a UI kit.
*/

/// <reference lib="dom" />

import { useMemo } from 'react';
import type { ComponentType } from 'react';
import Form from '@rjsf/core';
import type { FormProps } from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import type { AnyCommandRecord } from 'acture';
import { toJsonSchema } from 'acture';

/**
* The shape of an RJSF `<Form />`. Every RJSF theme's default export has
* exactly this type (`ComponentType<FormProps<any, RJSFSchema, any>>`), so a
* theme can be handed to {@link RjsfForm} as-is.
*/
export type RjsfFormComponent = ComponentType<FormProps>;

export interface RjsfFormProps {
command: AnyCommandRecord;
defaults?: Record<string, unknown>;
onSubmit: (params: unknown) => void;
onCancel: () => void;
/**
* The RJSF `<Form />` to render with. Defaults to the unstyled form from
* `@rjsf/core`. Pass a theme's default export — e.g. `@rjsf/shadcn` — to
* render through a design system:
*
* ```tsx
* import ShadcnForm from '@rjsf/shadcn';
* <RjsfForm form={ShadcnForm} command={cmd} onSubmit={…} onCancel={…} />
* ```
*
* acture never bundles a UI kit; the theme is the host's choice.
*
* **Must be referentially stable.** React reconciles by element *type*, so a
* theme built inline in JSX (`form={withTheme(myTheme)}`) is a new component
* type on every render of the parent: the form unmounts and remounts, and
* RJSF's internal `formData` — whatever the user had typed — is discarded and
* reset to `defaults`. Memoizing inside this component cannot fix that, since
* the changed identity arrives as the prop. Import a theme's default export
* (which is module-level, hence stable), or hoist your `withTheme(...)` call
* to a module-level `const`.
*/
form?: RjsfFormComponent;
}

export function RjsfForm(props: RjsfFormProps): React.ReactElement {
const { command, defaults, onSubmit, onCancel } = props;
const { command, defaults, onSubmit, onCancel, form } = props;

const inputSchema = useMemo(() => {
return toJsonSchema(command).inputSchema;
}, [command]);

const FormImpl: RjsfFormComponent = form ?? Form;

return (
<div
data-acture-rjsf
Expand All @@ -43,7 +80,7 @@ export function RjsfForm(props: RjsfFormProps): React.ReactElement {
{command.description}
</div>
) : null}
<Form
<FormImpl
schema={inputSchema}
formData={defaults}
validator={validator}
Expand All @@ -58,7 +95,7 @@ export function RjsfForm(props: RjsfFormProps): React.ReactElement {
Run
</button>
</div>
</Form>
</FormImpl>
<div style={{ opacity: 0.5, fontSize: '0.8em', marginTop: 6 }}>Esc to cancel</div>
</div>
);
Expand Down
Loading
Loading