From d5e9c745763bbd36ced168206a3bb8de5c4c70d3 Mon Sep 17 00:00:00 2001 From: Egor Shokurov Date: Fri, 12 Jun 2026 14:05:03 +0500 Subject: [PATCH] feat(registry): design-system registry infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a pluggable component + slot registry so the editor tree can be rendered entirely with any design system (MUI, Chakra, etc.) without forking the library. Public API - `SchemaBuilderRegistryProvider` — context provider; registries nest (inner merges over outer, so consumers override only what they need) - `mergeRegistry(base, override)` — explicit merge helper - `useComponent` / `useSlot` / `useSlotProps` — internal hooks consumed by every editor component Component adapters (Button, Input, Switch, Label, Badge, ButtonToggle) Slot adapters (Root, FieldFrame, FieldHeader, FieldMain, FieldActions, FieldBody, MobileModeSwitch, FullscreenToggle) Layout ownership DefaultFieldFrame and DefaultFieldHeader own their own layout classes; callers pass only semantic state (depth, expanded, hasErrors). Demo demo/pages/MuiRegistry.tsx — full working example of overriding every component and slot adapter with @mui/material primitives. demo/App.tsx — two-tab nav (Default | MUI Registry). Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 4 + README.md | 75 + demo/App.tsx | 35 +- demo/pages/MuiRegistry.tsx | 343 + package-lock.json | 7820 +++++++++++++---- package.json | 10 + .../SchemaEditor/AddFieldButton.tsx | 7 +- src/components/SchemaEditor/SchemaBuilder.tsx | 20 +- .../SchemaEditor/SchemaFieldsEditor.tsx | 20 +- .../SchemaEditor/SchemaJsonEditor.tsx | 20 +- .../SchemaEditor/SchemaPropertyEditor.tsx | 348 +- .../SchemaEditor/types/ArrayEditor.tsx | 27 +- .../SchemaEditor/types/BooleanEditor.tsx | 5 +- .../SchemaEditor/types/CombinatorEditor.tsx | 3 +- .../SchemaEditor/types/NumberEditor.tsx | 73 +- .../SchemaEditor/types/ObjectEditor.tsx | 4 +- .../SchemaEditor/types/StringEditor.tsx | 33 +- src/components/features/InferSchemaDialog.tsx | 3 +- src/index.ts | 13 + src/registry/SchemaBuilderRegistryContext.tsx | 78 + src/registry/defaults.tsx | 135 + src/registry/index.ts | 38 + src/registry/mergeRegistry.ts | 75 + src/registry/types.ts | 193 + .../SchemaFieldsEditor.test.tsx.snapshot | 4 +- .../types/ArrayEditor.test.tsx.snapshot | 2 +- .../types/NumberEditor.test.tsx.snapshot | 2 +- .../types/ObjectEditor.test.tsx.snapshot | 4 +- .../types/StringEditor.test.tsx.snapshot | 2 +- test/registry/components-registry.test.tsx | 110 + test/registry/mergeRegistry.test.ts | 60 + test/registry/provider-nesting.test.tsx | 104 + test/registry/slots-registry.test.tsx | 89 + 33 files changed, 7873 insertions(+), 1886 deletions(-) create mode 100644 .gitattributes create mode 100644 demo/pages/MuiRegistry.tsx create mode 100644 src/registry/SchemaBuilderRegistryContext.tsx create mode 100644 src/registry/defaults.tsx create mode 100644 src/registry/index.ts create mode 100644 src/registry/mergeRegistry.ts create mode 100644 src/registry/types.ts create mode 100644 test/registry/components-registry.test.tsx create mode 100644 test/registry/mergeRegistry.test.ts create mode 100644 test/registry/provider-nesting.test.tsx create mode 100644 test/registry/slots-registry.test.tsx diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dbc591e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Normalize line endings to LF for all text files on every platform. +# Prevents Windows autocrlf checkouts (CRLF) from failing `biome check` +# (biome.json enforces lineEnding: "lf"). +* text=auto eol=lf diff --git a/README.md b/README.md index 49cff08..e7d2782 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,81 @@ const customTranslation: Translation = { Use [`src/i18n/locales/en.ts`](https://github.com/lovasoa/jsonjoy-builder/blob/main/src/i18n/locales/en.ts) as the reference for all available keys. +## Plugin Registry + +The `registry` prop lets you replace any UI component or layout slot with your own design-system adapter. It works on ``, ``, and ``. + +### Replace leaf components + +```tsx +import { SchemaBuilder } from "jsonjoy-builder"; + + +``` + +### Replace layout slots + +```tsx +registry={{ + slots: { + // Layout wrappers that receive children and visual metadata + FieldFrame: MyFieldFrame, + FieldHeader: MyFieldHeader, + FieldMain: MyFieldMain, + FieldActions: MyFieldActions, + FieldBody: MyFieldBody, + }, + slotProps: { + // Extra props forwarded to each slot component + FieldFrame: { variant: "compact" }, + }, +}} +``` + +### Integration with a design system + +```tsx +// Wrap in your own ThemeProvider, pass adapters via registry +import { ThemeProvider, Button, TextField, Switch } from "./design-system"; + +const adapters = { + Button: (props) => + ))} + + {page === "default" ? : } + + ); +}; export default App; diff --git a/demo/pages/MuiRegistry.tsx b/demo/pages/MuiRegistry.tsx new file mode 100644 index 0000000..d69bc5d --- /dev/null +++ b/demo/pages/MuiRegistry.tsx @@ -0,0 +1,343 @@ +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import CssBaseline from "@mui/material/CssBaseline"; +import FormLabel from "@mui/material/FormLabel"; +import Paper from "@mui/material/Paper"; +import Switch from "@mui/material/Switch"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import TextField from "@mui/material/TextField"; +import ToggleButton from "@mui/material/ToggleButton"; +import { useState } from "react"; +import { + type JsonSchema, + SchemaBuilderRegistryProvider, + SchemaFieldsEditor, +} from "../../src/index.ts"; + +// ── MUI theme ─────────────────────────────────────────────────────────────── + +const theme = createTheme({ + palette: { primary: { main: "#1976d2" } }, + typography: { fontFamily: "Roboto, Helvetica, Arial, sans-serif" }, +}); + +// Remap library CSS tokens to MUI palette so non-registry-controlled surfaces +// (type dropdown, field name button, icons) still adopt MUI colours. +const muiTokenOverrides = ` +.jsonjoy { + --background: #ffffff; + --foreground: rgba(0, 0, 0, 0.87); + --card: #ffffff; + --card-foreground: rgba(0, 0, 0, 0.87); + --primary: #1976d2; + --primary-foreground: #ffffff; + --secondary: #f5f5f5; + --secondary-foreground: rgba(0, 0, 0, 0.87); + --muted: #f5f5f5; + --muted-foreground: rgba(0, 0, 0, 0.6); + --accent: #e3f2fd; + --accent-foreground: #1976d2; + --destructive: #d32f2f; + --destructive-foreground: #ffffff; + --border: rgba(0, 0, 0, 0.12); + --input: rgba(0, 0, 0, 0.23); + --ring: #1976d2; + --radius: 4px; + font-family: Roboto, Helvetica, Arial, sans-serif; +}`; + +// ── Component adapters ─────────────────────────────────────────────────────── + +// biome-ignore lint/suspicious/noExplicitAny: adapters intentionally accept the library's loose prop contracts +type AnyProps = any; + +const muiButtonVariant = (v?: string) => + v === "outline" + ? "outlined" + : v === "ghost" || v === "link" + ? "text" + : "contained"; + +function MuiButton({ + children, + variant, + size, + className: _c, + ...rest +}: AnyProps) { + return ( + + ); +} + +function MuiInput({ + className: _c, + type, + step, + min, + max, + "aria-invalid": ariaInvalid, + ...rest +}: AnyProps) { + // `label` arrives via {...rest} and folds into the TextField as an integrated caption. + return ( + + ); +} + +function MuiSwitch({ checked, onCheckedChange, disabled, id }: AnyProps) { + return ( + onCheckedChange?.(e.target.checked)} + /> + ); +} + +function MuiBadge({ children, variant }: AnyProps) { + return ( + + ); +} + +function MuiLabel({ children, htmlFor }: AnyProps) { + return {children}; +} + +function MuiButtonToggle({ + children, + onClick, + disabled, + "aria-pressed": ariaPressed, +}: AnyProps) { + const selected = ariaPressed === true || ariaPressed === "true"; + return ( + + {children} + + ); +} + +// ── Slot adapters ──────────────────────────────────────────────────────────── + +function MuiFieldFrame({ children, hasErrors }: AnyProps) { + return ( + + {children} + + ); +} + +function MuiFieldHeader({ children }: AnyProps) { + return ( + + {children} + + ); +} + +function MuiFieldActions({ children }: AnyProps) { + return ( + + {children} + + ); +} + +function MuiFieldBody({ children }: AnyProps) { + return ( + + {children} + + ); +} + +// ── Registry object ────────────────────────────────────────────────────────── + +const muiRegistry = { + components: { + Button: MuiButton as never, + Input: MuiInput as never, + Switch: MuiSwitch as never, + Badge: MuiBadge as never, + Label: MuiLabel as never, + ButtonToggle: MuiButtonToggle as never, + }, + slots: { + FieldFrame: MuiFieldFrame as never, + FieldHeader: MuiFieldHeader as never, + FieldActions: MuiFieldActions as never, + FieldBody: MuiFieldBody as never, + }, +}; + +// ── Sample schema ──────────────────────────────────────────────────────────── + +const sampleSchema: JsonSchema = { + type: "object", + properties: { + name: { type: "string", minLength: 2, maxLength: 100 }, + age: { type: "integer", minimum: 0, maximum: 150 }, + email: { type: "string", format: "email" }, + isActive: { type: "boolean" }, + tags: { type: "array", items: { type: "string" } }, + metadata: { + type: "object", + properties: { created: { type: "string", format: "date-time" } }, + additionalProperties: false, + }, + }, + required: ["name", "email"], +}; + +// ── Page ───────────────────────────────────────────────────────────────────── + +const MuiRegistry = () => { + const [schema, setSchema] = useState(sampleSchema); + + return ( + + + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS token override */} +