Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
177cbe8
chore: delete unreachable modules
hasanharman Sep 27, 2026
b53f051
fix: align components.json and Tailwind config with Tailwind v4
hasanharman Sep 27, 2026
f0f77dc
feat(registry): make registry.json the single Registry catalog
hasanharman Sep 27, 2026
60b7bc7
feat(registry): make registry items primitive-agnostic
hasanharman Sep 27, 2026
b00a291
feat(registry): publish every playground component
hasanharman Sep 27, 2026
3c29255
feat(registry): ship every template as a registry:block
hasanharman Sep 27, 2026
cc8a12b
chore: delete the unrendered block editor
hasanharman Sep 27, 2026
ddb73c8
refactor(form-builder): one module per Field variant
hasanharman Sep 27, 2026
03ade1e
refactor(codegen): one form generator with React Hook Form and TanSta…
hasanharman Sep 27, 2026
7363e6d
refactor(ui): keep the primitive library behind components/ui
hasanharman Sep 27, 2026
8761772
chore(ui): install Base UI alongside Radix
hasanharman Sep 27, 2026
e3ee99e
refactor(ui): migrate button to Base UI
hasanharman Sep 27, 2026
c22d4f0
refactor(ui): migrate label to Base UI
hasanharman Sep 27, 2026
39b2602
refactor(ui): migrate separator to Base UI
hasanharman Sep 27, 2026
597774f
refactor(ui): migrate checkbox to Base UI
hasanharman Sep 27, 2026
4f3ecb8
refactor(ui): migrate switch to Base UI
hasanharman Sep 27, 2026
f80d8a9
refactor(ui): migrate slider to Base UI
hasanharman Sep 27, 2026
f378db4
refactor(ui): migrate radio-group to Base UI
hasanharman Sep 27, 2026
12d9446
refactor(ui): migrate scroll-area to Base UI
hasanharman Sep 27, 2026
40d59a7
refactor(ui): migrate avatar to Base UI
hasanharman Sep 27, 2026
9434aa9
refactor(ui): migrate tabs to Base UI
hasanharman Sep 27, 2026
46f80d1
refactor(ui): migrate popover to Base UI
hasanharman Sep 27, 2026
207bd50
refactor(ui): migrate accordion to Base UI
hasanharman Sep 27, 2026
b218491
refactor(ui): migrate dialog to Base UI
hasanharman Sep 27, 2026
883fc23
refactor(ui): migrate sheet to Base UI
hasanharman Sep 27, 2026
2cf54ae
refactor(ui): migrate command to the base-vega wrapper
hasanharman Sep 27, 2026
447920b
refactor(ui): migrate select to Base UI
hasanharman Sep 27, 2026
19a1688
refactor(ui): migrate dropdown-menu to Base UI
hasanharman Sep 27, 2026
6ef3b3d
refactor(ui): migrate breadcrumb to Base UI
hasanharman Sep 27, 2026
9bd7060
refactor(ui): migrate sidebar and tooltip to Base UI
hasanharman Sep 27, 2026
34e3e75
refactor(ui): swap Radix icons for lucide in calendar and input-otp
hasanharman Sep 27, 2026
7a8292b
refactor(ui): sweep consumers for Base UI call-site differences
hasanharman Sep 27, 2026
1270c24
refactor(ui): finish the Base UI migration
hasanharman Sep 27, 2026
f1df964
docs: document the registry, field variants and domain terms
hasanharman Sep 27, 2026
940ae70
ci: typecheck, validate the registry, test and build on every PR
hasanharman Sep 27, 2026
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
check:
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
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm registry:validate
- run: pnpm vitest run
- run: pnpm build
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ next-env.d.ts
.env.development

# Ignore for WebStorm configuration
.idea
.idea
# shadcn registry output (built from registry.json by `shadcn build`)
/public/r

# generated-code typecheck fixtures (__tests__/form-code.spec.ts)
/__tests__/.generated
73 changes: 73 additions & 0 deletions .migration/project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Radix UI → Base UI (whole project)

2026-09-27 · strategy: golden pair via the shadcn CLI (`shadcn add` into a
scratch project with this repo's `components.json` and `style: base-vega`),
then per-component port of site customizations · verdict: **complete, 0
wrappers remain on Radix**.

Follows the official `migrate-radix-to-base` skill
(github.com/shadcn-ui/ui/tree/main/skills/migrate-radix-to-base).

## Changed

- `@base-ui/react@^1.8` added; every `@radix-ui/*` package removed.
- `components.json` style `new-york` → `base-vega` ("the classic shadcn/ui
look"). `npx shadcn info` now reports `base: "base"`, so future
`shadcn add` delivers Base UI components.
- `app/globals.css` imports `tw-animate-css` and `shadcn/tailwind.css` (the
`data-open`/`data-horizontal`/… variants base wrappers use) in place of
`tailwindcss-animate`; `--color-sidebar` alias added; body gets
`position: relative`; the app root is `isolate` (Base UI setup).
- One commit per wrapper, bottom-up:

| Wrapper | Base UI part(s) | Consumer changes |
|---|---|---|
| button | `button` | site variants kept (arrow, smile, gooey*, linkHover*, shine, ringHover, expandIcon); sponsors link → `render={<Link />}` + `nativeButton={false}` |
| label, separator, checkbox, switch, slider, radio-group, scroll-area, avatar, tabs | 1:1 | none |
| popover | Portal › Positioner › Popup | sponsors radar hover trigger → `render={<div />}` |
| accordion | Content → Panel | FAQ drops `type="single" collapsible` |
| dialog, sheet | Overlay → Backdrop, Content → Popup | none |
| command | cmdk kept; Base dialog; new `input-group` | none |
| select | Viewport → List, Positioner | edit dialog labels its `SelectValue` |
| dropdown-menu | `menu` | triggers → `render={<Button />}`; label wrapped in `DropdownMenuGroup` |
| breadcrumb | `useRender` + `mergeProps` | none |
| sidebar + tooltip | `useRender`; tooltip Portal › Positioner › Popup | links → `render={<Link />}`; site layout kept (12rem, in-page, no left border) |
| field | (not Radix) | base-vega version keys choice cards off `data-checked` |
| calendar, input-otp | (not Radix) | `@radix-ui/react-icons` → lucide |

Leftover scan: `git grep -nE "radix-ui|@radix-ui" -- '*.ts' '*.tsx'` is
clean apart from the registry test's own pattern. `__tests__/primitive-seam.spec.ts`
now fails if app code imports a primitive library outside `components/ui`.

## Left alone

- `components/ui/drawer.tsx`: vaul, not Radix (`DrawerTrigger asChild` in
the header is vaul's API).
- cmdk (command), sonner, input-otp, react-day-picker (calendar): not Radix.
- Registry items (`registry.json`): already primitive-agnostic, and verified
to install and typecheck in radix-nova, base-nova and new-york-v4 projects.

## Behavior changes

Flagged, not patched:

- **Look**: wrappers now use base-vega's (Tailwind v4) classes instead of the
2024 new-york ones: slightly different padding, rings and hover shades.
Each wrapper is its own commit if one should be restyled back.
- **Tabs** activate manually: arrow keys move focus, Enter/Space selects.
- **Select** aligns the list over the trigger (`alignItemWithTrigger`
default true) and `onValueChange` can receive `null`.
- **Accordion** allows one open item and collapsing by default.
- **Menus**: checkbox/radio items don't close on click (none used today).
- **Sidebar** no longer wraps a `TooltipProvider`; add one if
`SidebarMenuButton tooltip=` is ever used.

## Verify by hand

- Theme switch (header): open, pick Light/Dark/System, menu closes.
- Playground: add Select, Combobox, Date Picker, Signature Pad; open each
popup, pick a value, Escape closes, focus returns to the trigger.
- Signature Pad dialog: backdrop click and Escape close it.
- /components/*: Preview/Code tabs switch with click and keyboard.
- /templates/*: sidebar sits below the header, flow links switch previews.
- FAQ on the home page: items expand and collapse with the height animation.
35 changes: 35 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Domain glossary

Terms used across this codebase. Architecture words (module, interface,
seam, adapter, depth) follow their usual meanings in design reviews.

**Field variant**: a kind of form field the playground offers (Input, Phone,
Credit Card, …). Each is one module in `components/field-variants/` that
owns its defaults, builder settings, validation, default value, live
preview control and generated control. `FormFieldType.variant` names one.

**Form field**: one field the user added in the playground: a variant plus
the user's label, name, placeholder, required flag and variant settings.
Fields can be grouped into rows of two or three.

**Form library**: the library generated code targets: React Hook Form or
TanStack Form. Each has an adapter in `lib/form-code/` that supplies the
form hook, the `<form>` element and how a field binds its value.

**Schema expression**: a zod chain written once with `zx`
(`components/field-variants/schema-expr.ts`) and read two ways: as the
runtime schema the preview validates with, and as the source the generator
prints.

**Registry item**: a component, block or file published through the shadcn
registry. `registry.json` is the **Registry catalog**, the single source for
items' files, titles, descriptions and dependencies;
`lib/registry-catalog.ts` is its only reader.

**Template**: a ready-made form (auth flows, contact, newsletter) shipped as
a `registry:block`. A template can have **flows**, pages at their own routes
(`/sign-in`, `/sign-up`, …) that link to each other.

**Primitive seam**: `components/ui/`, the only modules allowed to import
the primitive library (Base UI). App code uses the wrappers' props, so
swapping or upgrading primitives stays inside `components/ui/`.
39 changes: 31 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ A dynamic form-building tool that allows users to create, customize, and validat
- [Usage](#usage)
- [Creating a Form](#creating-a-form)
- [Components](#components)
- [shadcn Registry](#shadcn-registry)
- [Project Structure](#project-structure)
- [Validation](#validation)
- [API](#api)
- [Form Submission](#form-submission)
Expand All @@ -37,6 +39,8 @@ A dynamic form-building tool that allows users to create, customize, and validat
- **Real-Time Validation**: Validate user inputs using the Zod library, ensuring data integrity and user-friendly feedback.
- **Responsive Design**: Built with Tailwind CSS, ensuring forms look great on all devices.
- **Customizable Components**: Leverage ShadCN components for a consistent and modern UI experience.
- **Two Form Libraries**: Generate React Hook Form or TanStack Form code, each starting with the one `shadcn add` command it needs.
- **shadcn Registry**: Every extra component and template installs with the shadcn CLI, in Radix and Base UI projects alike.
- **Server-Side Rendering**: Utilize Next.js for optimized performance and SEO.

## Live Demo
Expand All @@ -63,17 +67,19 @@ To get started with Form Builder, follow these steps:

3. Install the necessary dependencies:
```bash
npm install
pnpm install
```

## Usage

To start the development server, run:

```bash
npm run dev
pnpm dev
```

`pnpm dev` and `pnpm build` first build the registry into `public/r`. Run the tests with `pnpm test`.

Open your browser and navigate to `http://localhost:3000` to see the application in action.

### Creating a Form
Expand All @@ -85,13 +91,30 @@ Open your browser and navigate to `http://localhost:3000` to see the application

## Components

Form Builder consists of various reusable components:
The site's UI is shadcn/ui on [Base UI](https://base-ui.com) (`components.json` style `base-vega`). The extra form components (phone input, multi select, credit card, signature pad, …) are documented at [/components](https://www.shadcn-form.com/components).

## shadcn Registry

Everything under [/components](https://www.shadcn-form.com/components) and [/templates](https://www.shadcn-form.com/templates) is published as a [shadcn registry](https://ui.shadcn.com/docs/registry). Install an item with the shadcn CLI:

```bash
npx shadcn@latest add https://www.shadcn-form.com/r/phone-input.json
```

Items only use props every shadcn style shares, so they install into Radix (`radix-*`, `new-york`) and Base UI (`base-*`) projects alike. The generated form code in the playground starts with the command that installs everything it imports.

- `registry.json` is the source of truth: titles, descriptions, files, dependencies.
- `pnpm registry:build` runs `shadcn build` into `public/r`; `pnpm registry:validate` checks the source.
- `__tests__/registry-catalog.spec.ts` checks each item's declared dependencies against its imports.

## Project Structure

Domain terms are defined in [CONTEXT.md](CONTEXT.md).

- **FormContainer**: The main container for the form elements.
- **InputField**: A customizable input component.
- **SelectField**: Dropdown selection component.
- **CheckboxField**: A checkbox input component.
- **Button**: A styled button component for form submission.
- `components/field-variants/`: one module per playground field type (preview control, validation, default value, generated code). Add a field type by adding a module and listing it in `index.ts`.
- `lib/form-code/`: the form code generator, with one adapter per form library.
- `lib/registry-catalog.ts`: reads `registry.json` for docs pages, sidebars and install commands.
- `components/ui/`: shadcn wrappers; the only place allowed to import `@base-ui/react`.

## Validation

Expand Down
135 changes: 135 additions & 0 deletions __tests__/field-variants.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { isValidPhoneNumber } from 'react-phone-number-input'
import { z } from 'zod'
import { describe, expect, it } from 'vitest'

import { fieldVariants, getFieldVariant } from '@/components/field-variants'
import {
buildFormSchema,
createField,
formDefaultValues,
formSchemaSource,
} from '@/components/field-variants/form'
import { code, printValue, toSource, toZod, zx } from '@/components/field-variants/schema-expr'
import type { FormFieldType } from '@/types'

/** Evaluates generated schema source with the identifiers generated code imports. */
function evaluate(source: string) {
const js = source.replace(/<\w+>\(/g, '(') // drop TypeScript type arguments
return new Function('z', 'isValidPhoneNumber', 'File', `return ${js}`)(z, isValidPhoneNumber, File)
}

const jsonSchema = (schema: z.ZodType) =>
z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' })

describe('schema expressions', () => {
it('prints and builds the same chain', () => {
const expr = zx.string().min(3, { message: 'Too short' }).max(10)
expect(toSource(expr)).toBe('z.string().min(3, { message: "Too short" }).max(10)')
expect(toZod(expr).safeParse('ab').success).toBe(false)
expect(toZod(expr).safeParse('abcd').success).toBe(true)
})

it('prints code() values by their source and runs them by their value', () => {
const expr = zx.boolean().refine(code((value: boolean) => value, '(value) => value'))
expect(toSource(expr)).toBe('z.boolean().refine((value) => value)')
expect(toZod(expr).safeParse(false).success).toBe(false)
})

it('drops type arguments at runtime but keeps them in source', () => {
const expr = (zx.coerce as any)['number<string>']()
expect(toSource(expr)).toBe('z.coerce.number<string>()')
expect(toZod(expr).parse('4')).toBe(4)
})

it('refuses functions without a source form', () => {
expect(() => toSource(zx.instanceof(File))).toThrow(/code\(\)/)
})

it('prints nested schemas, dates and quoted keys', () => {
expect(printValue({ 'a-b': new Date(), c: [1, 'x'] })).toBe('{ "a-b": new Date(), c: [1, "x"] }')
expect(toSource(zx.object({ a: zx.string() }))).toBe('z.object({ a: z.string() })')
})
})

const fieldOf = (variant: string, overrides: Partial<FormFieldType> = {}): FormFieldType => ({
...createField(variant),
name: 'value',
...overrides,
})

describe.each(fieldVariants.map((variant) => [variant.name]))('%s variant', (name) => {
const variant = getFieldVariant(name)

it.each([true, false])('generates the schema it validates with (required: %s)', (required) => {
const field = fieldOf(name, { required })
const runtime = toZod(variant.schema(field))
const generated = evaluate(toSource(variant.schema(field)))
expect(jsonSchema(generated)).toEqual(jsonSchema(runtime))
})

it('accepts its default value when the field is optional', () => {
const field = fieldOf(name, { required: false })
const result = toZod(variant.schema(field)).safeParse(variant.defaultValue(field))
expect(result.error?.issues ?? []).toEqual([])
})

it('creates new fields from its defaults', () => {
const field = createField(name)
expect(field.label).toBe(variant.defaults.label)
expect(field.variant).toBe(name)
})

it('imports what its generated control uses', () => {
const field = fieldOf(name)
const control = variant.control(field, {
id: 'field.name',
name: 'field.name',
value: 'field.value',
onChange: (value) => `field.onChange(${value})`,
onBlur: 'field.onBlur',
invalid: 'invalid',
})
const imported = new Set(
variant
.imports(field)
.flatMap((line) => line.match(/import\s+(?:\{([^}]*)\}|(\w+))/)?.slice(1) ?? [])
.filter(Boolean)
.flatMap((names) => names.split(',').map((n) => n.trim()))
.filter(Boolean),
)
const components = [...control.matchAll(/<([A-Z]\w*)/g)].map((m) => m[1])
for (const component of components) {
if (component === 'FieldLabel') continue // provided by every form's Field imports
expect(imported, `${name} renders <${component}>`).toContain(component)
}
})
})

describe.each([['email'], ['number']])('Input of type %s', (type) => {
it.each([true, false])('generates the schema it validates with (required: %s)', (required) => {
const field = fieldOf('Input', { type, required, min: 1, max: 9 })
const expr = getFieldVariant('Input').schema(field)
expect(jsonSchema(evaluate(toSource(expr)))).toEqual(jsonSchema(toZod(expr)))
})
})

describe('form schema', () => {
it('keeps zod checks in generated source (zod v4 regression)', () => {
const fields = [fieldOf('Input', { name: 'username', min: 3, max: 20 })]
expect(formSchemaSource(fields)).toContain(
'username: z.string().min(3, { message: "Must be at least 3 characters" }).max(20',
)
})

it('quotes field names that are not identifiers', () => {
const fields = [fieldOf('Input', { name: 'first-name' })]
expect(formSchemaSource(fields)).toContain('"first-name": z.string()')
expect(Object.keys(buildFormSchema(fields).shape)).toEqual(['first-name'])
})

it('flattens rows into one schema and default values object', () => {
const fields = [fieldOf('Input', { name: 'a' }), [fieldOf('Switch', { name: 'b' }), fieldOf('Slider', { name: 'c' })]]
expect(Object.keys(buildFormSchema(fields).shape)).toEqual(['a', 'b', 'c'])
expect(formDefaultValues(fields)).toEqual({ a: '', b: false, c: 0 })
})
})
Loading
Loading