diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d208529 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 70587f2..70904bc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,9 @@ next-env.d.ts .env.development # Ignore for WebStorm configuration -.idea \ No newline at end of file +.idea +# shadcn registry output (built from registry.json by `shadcn build`) +/public/r + +# generated-code typecheck fixtures (__tests__/form-code.spec.ts) +/__tests__/.generated diff --git a/.migration/project.md b/.migration/project.md new file mode 100644 index 0000000..070d644 --- /dev/null +++ b/.migration/project.md @@ -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={}` + `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={
}` | +| 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={
- +
) diff --git a/app/(static)/components/page.tsx b/app/(static)/components/page.tsx index abcf57e..95a5486 100644 --- a/app/(static)/components/page.tsx +++ b/app/(static)/components/page.tsx @@ -1,8 +1,8 @@ import { redirect } from 'next/navigation' -import { components } from '@/constants/components' +import { documentedComponents } from '@/components/components' export default function ComponentsPage() { - const firstComponentPath = components[0]?.sub?.[0]?.path - redirect(firstComponentPath ?? '/') + const first = documentedComponents()[0] + redirect(first ? `/components/${first.name}` : '/') } diff --git a/app/(static)/readme/page.tsx b/app/(static)/readme/page.tsx index 83af9a9..a6b8223 100644 --- a/app/(static)/readme/page.tsx +++ b/app/(static)/readme/page.tsx @@ -1,7 +1,7 @@ import { Link } from 'next-view-transitions' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Separator } from '@radix-ui/react-separator' +import { Separator } from '@/components/ui/separator' interface UserLinkProps { href: string diff --git a/app/(static)/templates/[category]/(details)/[slug]/page.tsx b/app/(static)/templates/[category]/(details)/[slug]/page.tsx index fbda067..a9b0fd3 100644 --- a/app/(static)/templates/[category]/(details)/[slug]/page.tsx +++ b/app/(static)/templates/[category]/(details)/[slug]/page.tsx @@ -1,237 +1,49 @@ +import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { templates } from '@/constants/templates' -import BetterAuth from '@/components/templates/better-auth' -import ClerkAuth from '@/components/templates/clerk-auth' -import ClerkForgotPassword from '@/components/templates/clerk-forgot-password' -import ClerkResetPassword from '@/components/templates/clerk-reset-password' -import ClerkSignIn from '@/components/templates/clerk-sign-in' -import ClerkSignUp from '@/components/templates/clerk-sign-up' -import Contact from '@/components/templates/contact' -import FirebaseAuth from '@/components/templates/firebase-auth' -import ForgotPassword from '@/components/templates/forgot-password' -import Newsletter from '@/components/templates/newsletter' -import ResetPassword from '@/components/templates/reset-password' -import ShadcnAuth from '@/components/templates/shadcn-auth' -import SignIn from '@/components/templates/sign-in' -import SignUp from '@/components/templates/sign-up' -import SupabaseAuth from '@/components/templates/supabase-auth' +import { getTemplatePreview } from '@/components/templates/previews' import { TemplateBlockViewer } from '@/components/templates/template-block-viewer' - -const templatePages = { - 'shadcn-auth': ShadcnAuth, - 'clerk-auth': ClerkAuth, - 'supabase-auth': SupabaseAuth, - 'firebase-auth': FirebaseAuth, - 'better-auth': BetterAuth, - contact: Contact, - newsletter: Newsletter, -} - -const templateFeatures: Record = { - 'shadcn-auth': [ - 'Authentication starter for shadcn/ui only.', - 'Includes sign in, sign up, forgot password, and reset password flows in one provider page.', - 'No third-party auth provider dependencies.', - ], - 'clerk-auth': [ - 'Clerk-themed auth flows using shadcn/ui patterns.', - 'Includes sign in, sign up, forgot password, and reset password in one details page.', - 'Copy/paste ready: install Clerk with pnpm add @clerk/nextjs @clerk/elements before wiring handlers.', - ], - 'supabase-auth': [ - 'Supabase-ready auth provider template.', - 'Includes all core auth flows in one details page.', - 'Ready for Supabase auth action wiring.', - ], - 'firebase-auth': [ - 'Firebase auth provider template.', - 'Includes all core auth flows in one details page.', - 'Clear primary and secondary auth actions.', - ], - 'better-auth': [ - 'Composable Better Auth provider template.', - 'Includes all core auth flows in one details page.', - 'Prepared for server-action based auth.', - ], - contact: [ - 'Contact flow with name, email, and message.', - 'Validation-ready for support or sales inboxes.', - 'Accessible labels and feedback states.', - ], - newsletter: [ - 'Single-field email capture experience.', - 'High-conversion compact layout.', - 'Ready for newsletter provider integration.', - ], -} - -const templateCodeFiles: Record< - string, - Array<{ filename: string; label: string; path: string }> -> = { - 'shadcn-auth': [ - { - filename: 'sign-in', - label: 'sign-in.tsx', - path: 'components/templates/sign-in.tsx', - }, - { - filename: 'sign-up', - label: 'sign-up.tsx', - path: 'components/templates/sign-up.tsx', - }, - { - filename: 'forgot-password', - label: 'forgot-password.tsx', - path: 'components/templates/forgot-password.tsx', - }, - { - filename: 'reset-password', - label: 'reset-password.tsx', - path: 'components/templates/reset-password.tsx', - }, - { - filename: 'base-auth-shell', - label: 'base-auth-shell.tsx', - path: 'components/templates/base-auth-shell.tsx', - }, - ], - 'clerk-auth': [ - { - filename: 'clerk-sign-in', - label: 'clerk-sign-in.tsx', - path: 'components/templates/clerk-sign-in.tsx', - }, - { - filename: 'clerk-sign-up', - label: 'clerk-sign-up.tsx', - path: 'components/templates/clerk-sign-up.tsx', - }, - { - filename: 'clerk-forgot-password', - label: 'clerk-forgot-password.tsx', - path: 'components/templates/clerk-forgot-password.tsx', - }, - { - filename: 'clerk-reset-password', - label: 'clerk-reset-password.tsx', - path: 'components/templates/clerk-reset-password.tsx', - }, - { - filename: 'base-auth-shell', - label: 'base-auth-shell.tsx', - path: 'components/templates/base-auth-shell.tsx', - }, - ], -} - -const templateInstallBlocks: Record = { - 'shadcn-auth': 'login-05', -} - -const shadcnFlowPages = { - 'sign-in': SignIn, - 'sign-up': SignUp, - 'forgot-password': ForgotPassword, - 'reset-password': ResetPassword, -} as const - -const clerkFlowPages = { - 'sign-in': ClerkSignIn, - 'sign-up': ClerkSignUp, - 'forgot-password': ClerkForgotPassword, - 'reset-password': ClerkResetPassword, -} as const - -type ShadcnFlow = keyof typeof shadcnFlowPages -type ClerkFlow = keyof typeof clerkFlowPages - -function getShadcnFlow(flow?: string): ShadcnFlow { - if ( - flow === 'sign-in' || - flow === 'sign-up' || - flow === 'forgot-password' || - flow === 'reset-password' - ) { - return flow - } - - return 'sign-in' -} - -function getClerkFlow(flow?: string): ClerkFlow { - if ( - flow === 'sign-in' || - flow === 'sign-up' || - flow === 'forgot-password' || - flow === 'reset-password' - ) { - return flow - } - - return 'sign-in' -} +import { findTemplate, resolveFlow } from '@/constants/templates' interface TemplateDetailsPageProps { params: Promise<{ slug: string; category: string }> searchParams: Promise<{ flow?: string }> } -export default async function Page({ +export async function generateMetadata({ params, - searchParams, -}: TemplateDetailsPageProps) { - const { slug, category } = await params - const { flow } = await searchParams - const PreviewComponent = templatePages[slug as keyof typeof templatePages] +}: Pick): Promise { + const { category, slug } = await params + const template = findTemplate(category, slug) + if (!template) return {} + return { title: `${template.title} - Shadcn Form Builder`, description: template.description } +} - const categoryConfig = templates.find((template) => - template.path.endsWith(`/${category}`), - ) - const isValidTemplateForCategory = categoryConfig?.sub.some( - (entry) => entry.path === `/templates/${category}/${slug}`, - ) - const templateConfig = categoryConfig?.sub.find( - (entry) => entry.path === `/templates/${category}/${slug}`, - ) +export default async function Page({ params, searchParams }: TemplateDetailsPageProps) { + const { slug, category } = await params + const { flow: flowId } = await searchParams + const template = findTemplate(category, slug) + const flow = template && resolveFlow(template, flowId) + const Preview = template && getTemplatePreview(template.name, flow?.id) - if (!PreviewComponent || !isValidTemplateForCategory || !templateConfig) { + if (!template || !Preview) { notFound() } - const isShadcnAuthPage = slug === 'shadcn-auth' - const isClerkAuthPage = slug === 'clerk-auth' - const activeShadcnFlow = getShadcnFlow(flow) - const ActiveShadcnFlow = shadcnFlowPages[activeShadcnFlow] - const activeClerkFlow = getClerkFlow(flow) - const ActiveClerkFlow = clerkFlowPages[activeClerkFlow] - const ActivePreview = isShadcnAuthPage - ? ActiveShadcnFlow - : isClerkAuthPage - ? ActiveClerkFlow - : PreviewComponent - return (
-

{templateConfig.title}

-

- {templateConfig.description} -

+

{template.title}

+

{template.description}

How to use

    +
  • Use the left sidebar to pick a flow or switch templates.
  • +
  • Use Preview and Code in the block toolbar to test and copy quickly.
  • - Use the left sidebar to pick the auth flow or switch templates. -
  • -
  • - Use Preview and Code in the block toolbar to test and copy - quickly. -
  • -
  • - Use the install command to scaffold this block in your own app. + Run the install command to add every file, dependency and + component this template needs to your app.
@@ -239,20 +51,15 @@ export default async function Page({
- - + +

Features

    - {(templateFeatures[slug] ?? []).map((feature) => ( + {template.features.map((feature) => (
  • {feature}
  • ))}
diff --git a/app/(static)/templates/[category]/page.tsx b/app/(static)/templates/[category]/page.tsx index 694f5ea..902bd0e 100644 --- a/app/(static)/templates/[category]/page.tsx +++ b/app/(static)/templates/[category]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' -import { templates } from '@/constants/templates' +import { templateCategories } from '@/constants/templates' interface CategoryPageProps { params: Promise<{ @@ -10,11 +10,6 @@ interface CategoryPageProps { export default async function CategoryPage({ params }: CategoryPageProps) { const { category } = await params - - const matchingTemplate = templates.find((template) => - template.path.endsWith(`/${category}`), - ) - - const defaultTemplatePath = matchingTemplate?.sub?.[0]?.path - redirect(defaultTemplatePath ?? '/templates') + const first = templateCategories.find((entry) => entry.id === category)?.templates[0] + redirect(first?.path ?? '/templates') } diff --git a/app/(static)/templates/page.tsx b/app/(static)/templates/page.tsx index 9a1090e..c5642a0 100644 --- a/app/(static)/templates/page.tsx +++ b/app/(static)/templates/page.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react' import { Link } from 'next-view-transitions' -import { templates } from '@/constants/templates' +import { templateCategories } from '@/constants/templates' const iconMap = { shield: Shield, @@ -159,7 +159,7 @@ function LoginMock() { export default function TemplatesPage() { return (
- {templates.map((section) => ( + {templateCategories.map((section) => (
- {section.sub.map((template) => { + {section.templates.map((template) => { const Icon = iconMap[template.icon] return ( diff --git a/app/api/file/[filename]/route.ts b/app/api/file/[filename]/route.ts deleted file mode 100644 index ae3570c..0000000 --- a/app/api/file/[filename]/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from 'next/server' -import { promises as fs } from 'fs' -import path from 'path' - -export async function GET( - request: Request, - { params }: { params: Promise<{ filename: string }> }, -) { - try { - const { filename } = await params - // Validate filename to prevent directory traversal attacks - if (!filename.match(/^[a-zA-Z0-9-]+$/)) { - return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) - } - - const filePath = path.join( - process.cwd(), - 'components', - 'templates', - `${filename}.tsx`, - ) - - const fileContents = await fs.readFile(filePath, 'utf8') - return NextResponse.json({ content: fileContents }) - } catch (error) { - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } -} diff --git a/app/globals.css b/app/globals.css index a735b92..5653427 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,11 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; @custom-variant dark (&:is(.dark *)); @plugin "@tailwindcss/typography"; -@plugin "tailwindcss-animate"; + +/* Form layouts build `col-span-${n}` at runtime; keep the whole range. */ +@source inline("col-span-{1,2,3,4,5,6,7,8,9,10,11,12}"); :root { --background: hsl(0 0% 100%); @@ -99,6 +103,7 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar-background); --color-sidebar-background: var(--sidebar-background); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -122,16 +127,6 @@ --animate-border-beam: border-beam calc(var(--duration)*1s) infinite linear; --animate-ripple: ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite; - @keyframes accordion-down { - from { height: 0; } - to { height: var(--radix-accordion-content-height); } - } - - @keyframes accordion-up { - from { height: var(--radix-accordion-content-height); } - to { height: 0; } - } - @keyframes marquee { from { transform: translateX(0); } to { transform: translateX(calc(-100% - var(--gap))); } @@ -168,6 +163,8 @@ @layer base { body { + /* Base UI: keeps iOS 26+ Safari from mispositioning popups. */ + position: relative; @apply bg-background text-foreground; font-family: Arial, Helvetica, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index c6b31f0..0b6693f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -78,7 +78,8 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > -
+ {/* Base UI portals render outside this root; isolate keeps app z-indexes below them. */} +
diff --git a/components.json b/components.json index 05b4a12..34cb3a0 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "base-vega", "rsc": true, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", + "config": "", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true, @@ -15,7 +15,7 @@ "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", - "hooks": "@/hooks", - "form": "@/components/form" - } -} \ No newline at end of file + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/animated-tooltip.tsx b/components/animated-tooltip.tsx deleted file mode 100644 index ad76c32..0000000 --- a/components/animated-tooltip.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client' -import Image from 'next/image' -import React, { useState } from 'react' -import { - motion, - useTransform, - AnimatePresence, - useMotionValue, - useSpring, -} from 'framer-motion' - -export const AnimatedTooltip = ({ - items, - size = 40, - spacingClass = '-mr-4', -}: { - items: { - id: number - name: string - designation: string - image: string - }[] - size?: number - spacingClass?: string -}) => { - const [hoveredIndex, setHoveredIndex] = useState(null) - const springConfig = { stiffness: 100, damping: 5 } - const x = useMotionValue(0) // going to set this value on mouse move - // rotate the tooltip - const rotate = useSpring( - useTransform(x, [-100, 100], [-45, 45]), - springConfig, - ) - // translate the tooltip - const translateX = useSpring( - useTransform(x, [-100, 100], [-50, 50]), - springConfig, - ) - const handleMouseMove = (event: any) => { - const halfWidth = event.target.offsetWidth / 2 - x.set(event.nativeEvent.offsetX - halfWidth) // set the x value, which is then used in transform and rotate - } - - return ( - <> - {items.map((item, idx) => ( -
setHoveredIndex(item.id)} - onMouseLeave={() => setHoveredIndex(null)} - > - - {hoveredIndex === item.id && ( - -
-
-
- {item.name} -
-
{item.designation}
- - )} - - {item.name} -
- ))} - - ) -} diff --git a/components/blocks/checkbox.tsx b/components/blocks/checkbox.tsx deleted file mode 100644 index dcb8ced..0000000 --- a/components/blocks/checkbox.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// components/editor/blocks/checkbox.tsx -import { defaultProps } from '@blocknote/core' -import { createReactBlockSpec } from '@blocknote/react' -import * as CheckboxPrimitive from '@radix-ui/react-checkbox' -import { CheckIcon } from '@radix-ui/react-icons' -import { cn } from '@/lib/utils' - -export const checkboxTypes = [ - { - title: 'Default', - value: 'default', - }, - { - title: 'Checked', - value: 'checked', - }, -] as const - -export const Checkbox = createReactBlockSpec( - { - type: 'checkbox', - propSchema: { - textAlignment: defaultProps.textAlignment, - textColor: defaultProps.textColor, - checked: { - default: false, - }, - label: { - default: '', - }, - }, - content: 'inline', - }, - { - render: (props) => { - return ( -
- { - const isChecked = checked === true - props.editor.updateBlock(props.block, { - props: { checked: isChecked }, - }) - }} - className={cn( - 'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', - )} - > - - - - -
-
- ) - }, - }, -) diff --git a/components/blocks/schema.ts b/components/blocks/schema.ts deleted file mode 100644 index 314907e..0000000 --- a/components/blocks/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -// components/editor/schema.ts -import { z } from 'zod' - -export const editorFormSchema = z.object({ - title: z.string().min(2, { - message: 'Title must be at least 2 characters.', - }), - content: z.array(z.any()).min(1, { - message: 'Content cannot be empty.', - }), - description: z.string().optional(), -}) - -export type EditorFormValues = z.infer diff --git a/components/components/autocomplete-form.tsx b/components/components/autocomplete-form.tsx deleted file mode 100644 index 65ebae4..0000000 --- a/components/components/autocomplete-form.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Form, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import Autocomplete from '@/components/ui/autocomplete' - -const FormSchema = z.object({ - framework: z.string().min(1, { message: 'Please select a framework' }), -}) - -type AutocompleteFormData = z.infer - -export function AutocompleteForm() { - const form = useForm({ - resolver: zodResolver(FormSchema), - }) - - const onSubmit = (data: AutocompleteFormData) => { - console.log('HEY', data) - toast( -
-        {JSON.stringify(data, null, 2)}
-      
, - ) - } - - return ( - - - ( - -
- What is your favorite framework? -
- - - Please type and select your favorite framework? - - -
- )} - /> - - - - ) -} diff --git a/components/components/autocomplete.tsx b/components/components/autocomplete.tsx index a9f40f3..528a348 100644 --- a/components/components/autocomplete.tsx +++ b/components/components/autocomplete.tsx @@ -12,9 +12,7 @@ const usageCode = `import Autocomplete from '@/components/ui/autocomplete' export default function AutocompletePreview() { return ( } previewCode={previewCode} usageCode={usageCode} @@ -23,7 +21,6 @@ export default function AutocompletePreview() { 'Keyboard navigation (ArrowUp, ArrowDown, Enter, Escape).', 'Accessible listbox semantics for suggestions.', ]} - dependencies={['use-debounce']} /> ) } diff --git a/components/components/availability-picker.tsx b/components/components/availability-picker.tsx index 34a62f9..e1eb6ae 100644 --- a/components/components/availability-picker.tsx +++ b/components/components/availability-picker.tsx @@ -15,9 +15,7 @@ export default function AvailabilityPickerPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/color-picker.tsx b/components/components/color-picker.tsx index 6e3953a..ca261a8 100644 --- a/components/components/color-picker.tsx +++ b/components/components/color-picker.tsx @@ -17,9 +17,7 @@ export default function ColorPickerPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/component-doc-shell.tsx b/components/components/component-doc-shell.tsx index 46b99b0..faf3d0f 100644 --- a/components/components/component-doc-shell.tsx +++ b/components/components/component-doc-shell.tsx @@ -6,31 +6,33 @@ import { Link } from 'next-view-transitions' import Code from '@/components/code' import { Card } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + getRegistryItem, + installCommand, + registryItemUrl, +} from '@/lib/registry-catalog' type ComponentDocShellProps = { - title: string - slug: string - description: string + /** Registry item name; title, description and install command come from the catalog. */ + name: string preview: React.ReactNode previewCode: string usageCode: string features?: string[] - dependencies?: string[] notes?: React.ReactNode } export function ComponentDocShell({ - title, - slug, - description, + name, preview, previewCode, usageCode, features = [], - dependencies = [], notes, }: ComponentDocShellProps) { - const installationCode = `npx shadcn@latest add https://www.shadcn-form.com/registry/${slug}.json` + const item = getRegistryItem(name) + if (!item) throw new Error(`Unknown registry item: ${name}`) + const { title, description } = item return (
@@ -70,23 +72,14 @@ export function ComponentDocShell({

Installation

- +

- Registry JSON:{' '} - - /registry/{slug}.json + The CLI installs every dependency listed in the{' '} + + registry item + .

- {dependencies.length > 0 ? ( - <> -

Install dependencies:

- - - ) : null}
diff --git a/components/components/components-sidebar.tsx b/components/components/components-sidebar.tsx index 8fe591e..2e41956 100644 --- a/components/components/components-sidebar.tsx +++ b/components/components/components-sidebar.tsx @@ -15,44 +15,41 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, } from '@/components/ui/sidebar' -import { components } from '@/constants/components' +import { documentedComponents } from '@/components/components' export function AppSidebar() { const pathname = usePathname() + const items = documentedComponents() return ( - {components.map((item) => ( - - - - {item.title} - - - - {item.sub.length} - - {item.sub?.length ? ( - - {item.sub.map((subItem) => ( - - - - {subItem.title} - - - - ))} - - ) : null} - - ))} + + } + > + Components + + {items.length} + + {items.map((item) => { + const path = `/components/${item.name}` + return ( + + } + > + {item.title} + + + ) + })} + + diff --git a/components/components/credit-card-form.tsx b/components/components/credit-card-form.tsx deleted file mode 100644 index 440bce5..0000000 --- a/components/components/credit-card-form.tsx +++ /dev/null @@ -1,211 +0,0 @@ -'use client' - -import React, { useState } from 'react' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import { CreditCard, type CreditCardValue } from '@/components/ui/credit-card' - -// Enhanced validation schema -const FormSchema = z - .object({ - cardholderName: z - .string() - .min(2, 'Cardholder name must be at least 2 characters') - .max(50, 'Cardholder name must be less than 50 characters'), - - cardNumber: z - .string() - .min(1, 'Card number is required') - .refine((value) => { - const cleanNumber = value.replace(/\s/g, '') - return /^\d{13,19}$/.test(cleanNumber) - }, 'Invalid card number format'), - - expiryMonth: z - .string() - .min(1, 'Expiry month is required') - .refine((value) => { - const month = parseInt(value) - return month >= 1 && month <= 12 - }, 'Invalid month'), - - expiryYear: z - .string() - .min(1, 'Expiry year is required') - .refine((value) => { - const year = parseInt(value) - const currentYear = new Date().getFullYear() - return year >= currentYear && year <= currentYear + 20 - }, 'Invalid year'), - - cvv: z - .string() - .min(3, 'CVV must be at least 3 digits') - .max(4, 'CVV must be at most 4 digits') - .refine((value) => /^\d+$/.test(value), 'CVV must contain only digits'), - }) - - // Add expiry date validation - .refine( - (data) => { - if (!data.expiryMonth || !data.expiryYear) return true // Let individual field validation handle this - - const currentDate = new Date() - const currentYear = currentDate.getFullYear() - const currentMonth = currentDate.getMonth() + 1 - const expiryYear = parseInt(data.expiryYear) - const expiryMonth = parseInt(data.expiryMonth) - - return ( - expiryYear > currentYear || - (expiryYear === currentYear && expiryMonth >= currentMonth) - ) - }, - { - message: 'Card has expired', - path: ['expiryYear'], // Show error on year field - }, - ) - -type CreditCardFormData = z.infer - -export function CreditCardForm() { - const [creditCard, setCreditCard] = useState({ - cardholderName: '', - cardNumber: '', - expiryMonth: '', - expiryYear: '', - cvv: '', - }) - - const [isCardValid, setIsCardValid] = useState(false) - - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - cardholderName: '', - cardNumber: '', - expiryMonth: '', - expiryYear: '', - cvv: '', - }, - mode: 'onChange', // Enable real-time validation - }) - - const handleCreditCardChange = (value: CreditCardValue) => { - setCreditCard(value) - - // Update form values - form.setValue('cardholderName', value.cardholderName, { - shouldValidate: true, - }) - form.setValue('cardNumber', value.cardNumber, { shouldValidate: true }) - form.setValue('expiryMonth', value.expiryMonth, { shouldValidate: true }) - form.setValue('expiryYear', value.expiryYear, { shouldValidate: true }) - form.setValue('cvv', value.cvv, { shouldValidate: true }) - } - - const handleValidationChange = (isValid: boolean, errors: any) => { - setIsCardValid(isValid) - } - - const onSubmit = (data: CreditCardFormData) => { - console.log('Form submitted:', data) - toast.success( -
-

Payment Information Submitted

-
-          {JSON.stringify(data, null, 2)}
-        
-
, - ) - } - - return ( -
-
-

Payment Information

-

Enter your credit card details

-
- -
- - ( - - Credit Card Information - - - - - All fields are required. Your information is secure and - encrypted. - - - - )} - /> - - {/* Hidden fields to capture validation errors */} -
- } - /> - } - /> - } - /> - } - /> -
- -
- -
- - -
- ) -} diff --git a/components/components/credit-card.tsx b/components/components/credit-card.tsx index 4551cbf..7381d4a 100644 --- a/components/components/credit-card.tsx +++ b/components/components/credit-card.tsx @@ -31,9 +31,7 @@ export default function CreditCardPreview() { return ( } previewCode={previewCode} usageCode={usageCode} @@ -42,7 +40,6 @@ export default function CreditCardPreview() { 'Animated card preview with front/back transitions.', 'Validation hooks for expiry and CVV.', ]} - dependencies={['framer-motion']} /> ) } diff --git a/components/components/cron-expression-builder.tsx b/components/components/cron-expression-builder.tsx index b47580d..e2f8e4f 100644 --- a/components/components/cron-expression-builder.tsx +++ b/components/components/cron-expression-builder.tsx @@ -15,9 +15,7 @@ export default function CronExpressionBuilderPreview() { return ( setSummary(v.human)} />} previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/datetime-picker.tsx b/components/components/datetime-picker.tsx new file mode 100644 index 0000000..a4eb138 --- /dev/null +++ b/components/components/datetime-picker.tsx @@ -0,0 +1,49 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { DatetimePicker } from '@/components/ui/datetime-picker' + +const format = `[ + ['months', 'days', 'years'], + ['hours', 'minutes', 'am/pm'], +]` + +const previewCode = `` + +const usageCode = `import { DatetimePicker } from '@/components/ui/datetime-picker' + +const [date, setDate] = React.useState(new Date()) + +` + +export default function DatetimePickerPreview() { + const [date, setDate] = React.useState(new Date()) + + return ( + + } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Arrow keys step each segment; typing jumps to the next one.', + 'Configurable segment order and 12/24-hour clocks.', + ]} + /> + ) +} diff --git a/components/components/emoji-picker.tsx b/components/components/emoji-picker.tsx index 6c163e5..0eaa0c6 100644 --- a/components/components/emoji-picker.tsx +++ b/components/components/emoji-picker.tsx @@ -15,9 +15,7 @@ export default function EmojiPickerPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/file-upload.tsx b/components/components/file-upload.tsx new file mode 100644 index 0000000..6ff0ece --- /dev/null +++ b/components/components/file-upload.tsx @@ -0,0 +1,83 @@ +'use client' + +import * as React from 'react' +import { Paperclip, Upload } from 'lucide-react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { + FileInput, + FileUploader, + FileUploaderContent, + FileUploaderItem, +} from '@/components/ui/file-upload' + +const dropzoneOptions = { + maxFiles: 5, + maxSize: 1024 * 1024 * 4, + multiple: true, +} + +const previewCode = ` + +

Drop files or click to upload

+
+ + {files?.map((file, i) => ( + + {file.name} + + ))} + +
` + +const usageCode = `import { + FileInput, + FileUploader, + FileUploaderContent, + FileUploaderItem, +} from '@/components/ui/file-upload' + +const [files, setFiles] = React.useState(null) +const dropzoneOptions = { maxFiles: 5, maxSize: 1024 * 1024 * 4, multiple: true } + +${previewCode}` + +export default function FileUploadPreview() { + const [files, setFiles] = React.useState(null) + + return ( + + +
+ + Drop files or click to upload (max 5, 4MB each) +
+
+ + {files?.map((file, i) => ( + + + {file.name} + + ))} + + + } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Drag and drop or click, powered by react-dropzone.', + 'File count and size limits with toast feedback (sonner).', + 'Arrow keys move between files; Delete removes one.', + ]} + /> + ) +} diff --git a/components/components/image-upload-dropzone.tsx b/components/components/image-upload-dropzone.tsx index eed3912..6a26042 100644 --- a/components/components/image-upload-dropzone.tsx +++ b/components/components/image-upload-dropzone.tsx @@ -15,9 +15,7 @@ export default function ImageUploadDropzonePreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/index.ts b/components/components/index.ts new file mode 100644 index 0000000..762db76 --- /dev/null +++ b/components/components/index.ts @@ -0,0 +1,40 @@ +import dynamic from 'next/dynamic' +import type { ComponentType } from 'react' + +import { listRegistryItems } from '@/lib/registry-catalog' + +/** + * Docs page for each registry item that has one, keyed by registry item name. + * Title, description and install command live in registry.json, not here. + */ +export const componentDocs: Record = { + autocomplete: dynamic(() => import('./autocomplete')), + 'availability-picker': dynamic(() => import('./availability-picker')), + 'color-picker': dynamic(() => import('./color-picker')), + 'credit-card': dynamic(() => import('./credit-card')), + 'cron-expression-builder': dynamic(() => import('./cron-expression-builder')), + 'datetime-picker': dynamic(() => import('./datetime-picker')), + 'emoji-picker': dynamic(() => import('./emoji-picker')), + 'file-upload': dynamic(() => import('./file-upload')), + 'image-upload-dropzone': dynamic(() => import('./image-upload-dropzone')), + 'inline-editable-field': dynamic(() => import('./inline-editable-field')), + 'location-input': dynamic(() => import('./location-input')), + 'masked-input': dynamic(() => import('./masked-input')), + 'multi-select': dynamic(() => import('./multi-select')), + 'password-input': dynamic(() => import('./password-input')), + 'phone-input': dynamic(() => import('./phone-input')), + rating: dynamic(() => import('./rating')), + 'signature-input': dynamic(() => import('./signature-input')), + 'signature-pad': dynamic(() => import('./signature-pad')), + 'smart-datetime-input': dynamic(() => import('./smart-datetime-input')), + 'sortable-list-input': dynamic(() => import('./sortable-list-input')), + 'tags-input': dynamic(() => import('./tags-input')), + 'token-input': dynamic(() => import('./token-input')), + 'transfer-list': dynamic(() => import('./transfer-list')), + 'tree-select': dynamic(() => import('./tree-select')), +} + +/** Registry items with a docs page, in catalog order. */ +export function documentedComponents() { + return listRegistryItems('registry:ui').filter((item) => item.name in componentDocs) +} diff --git a/components/components/inline-editable-field.tsx b/components/components/inline-editable-field.tsx index d920cc5..1870fdb 100644 --- a/components/components/inline-editable-field.tsx +++ b/components/components/inline-editable-field.tsx @@ -15,9 +15,7 @@ export default function InlineEditableFieldPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/location-form.tsx b/components/components/location-form.tsx deleted file mode 100644 index a307671..0000000 --- a/components/components/location-form.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client' - -import { useState } from 'react' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Form, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import LocationSelector from '@/components/ui/location-input' - -const FormSchema = z.object({ - location: z.tuple([ - z.string().min(1, { message: 'Country is required' }), - z.string().optional(), // State name, optional - ]), -}) - -export function LocationForm() { - const [countryName, setCountryName] = useState('') - const [stateName, setStateName] = useState('') - - const form = useForm>({ - resolver: zodResolver(FormSchema), - }) - - function onSubmit(data: z.infer) { - toast( -
-        {JSON.stringify(data, null, 2)}
-      
, - ) - } - - return ( -
- - ( - -
- Country Selector -
- { - setCountryName(country?.name || '') - form.setValue(field.name, [ - country?.name || '', - stateName || '', - ]) - }} - onStateChange={(state) => { - setStateName(state?.name || '') - form.setValue(field.name, [ - countryName || '', - state?.name || '', - ]) - }} - /> - - Please select state after selecting your country - - -
- )} - /> - - - - ) -} diff --git a/components/components/location-input.tsx b/components/components/location-input.tsx index 1e23a5d..00b141c 100644 --- a/components/components/location-input.tsx +++ b/components/components/location-input.tsx @@ -18,9 +18,7 @@ const usageCode = `import LocationSelector from '@/components/ui/location-input' export default function LocationInputPreview() { return ( } previewCode={previewCode} usageCode={usageCode} @@ -29,12 +27,6 @@ export default function LocationInputPreview() { 'State list automatically filtered by selected country.', 'Controlled callbacks for form integrations.', ]} - notes={ -

- Add countries.json and states.json in your{' '} - /data directory before using this component. -

- } /> ) } diff --git a/components/components/masked-input.tsx b/components/components/masked-input.tsx index 4496f0b..cfd7d4b 100644 --- a/components/components/masked-input.tsx +++ b/components/components/masked-input.tsx @@ -15,9 +15,7 @@ export default function MaskedInputPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/multi-select.tsx b/components/components/multi-select.tsx new file mode 100644 index 0000000..ef667dc --- /dev/null +++ b/components/components/multi-select.tsx @@ -0,0 +1,73 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from '@/components/ui/multi-select' + +const options = ['React', 'Vue', 'Svelte', 'Solid', 'Angular'] + +const previewCode = ` + + + + + + React + Vue + + +` + +const usageCode = `import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from '@/components/ui/multi-select' + +const [values, setValues] = React.useState([]) + +${previewCode}` + +export default function MultiSelectPreview() { + const [values, setValues] = React.useState(['React']) + + return ( + + + + + + + {options.map((option) => ( + + {option} + + ))} + + + + } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Type to filter; Enter toggles the highlighted option.', + 'Selected values render as removable badges.', + 'Backspace removes the last value.', + ]} + /> + ) +} diff --git a/components/components/password-input.tsx b/components/components/password-input.tsx new file mode 100644 index 0000000..2083234 --- /dev/null +++ b/components/components/password-input.tsx @@ -0,0 +1,38 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { PasswordInput } from '@/components/ui/password-input' + +const previewCode = ` setPassword(e.target.value)} />` + +const usageCode = `import { PasswordInput } from '@/components/ui/password-input' + +const [password, setPassword] = React.useState('') + +${previewCode}` + +export default function PasswordInputPreview() { + const [password, setPassword] = React.useState('') + + return ( + setPassword(event.target.value)} + /> + } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Show/hide toggle, disabled while the field is empty.', + 'Hides the browser’s built-in reveal button.', + 'Accepts every native input prop.', + ]} + /> + ) +} diff --git a/components/components/phone-input.tsx b/components/components/phone-input.tsx new file mode 100644 index 0000000..91a1966 --- /dev/null +++ b/components/components/phone-input.tsx @@ -0,0 +1,37 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { PhoneInput } from '@/components/ui/phone-input' + +const previewCode = `` + +const usageCode = `import { PhoneInput } from '@/components/ui/phone-input' + +const [phone, setPhone] = React.useState('') + +` + +export default function PhoneInputPreview() { + const [phone, setPhone] = React.useState('') + + return ( + + setPhone(v ?? '')} /> +

Value: {phone || '—'}

+
+ } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Returns E.164 formatted numbers (e.g. +14155552671).', + 'Searchable country list with flags.', + 'Formats as you type for the selected country.', + ]} + /> + ) +} diff --git a/components/components/rating.tsx b/components/components/rating.tsx new file mode 100644 index 0000000..c0ca294 --- /dev/null +++ b/components/components/rating.tsx @@ -0,0 +1,34 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { Rating } from '@/components/ui/rating' + +const previewCode = ` +` + +const usageCode = `import { Rating } from '@/components/ui/rating' + +const [rating, setRating] = React.useState(3) + +` + +export default function RatingPreview() { + const [rating, setRating] = React.useState(3) + + return ( + + + +
+ } + previewCode={previewCode} + usageCode={usageCode} + features={['Star, heart and thumbs-up icons.', 'sm, md and lg sizes.', 'Read-only mode for display.']} + /> + ) +} diff --git a/components/components/signature-form.tsx b/components/components/signature-form.tsx deleted file mode 100644 index 0e599f9..0000000 --- a/components/components/signature-form.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { useRef } from 'react' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Form, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import SignatureInput from '@/components/ui/signature-input' - -const FormSchema = z.object({ - signature: z.string().min(1, { message: 'Please sign the form' }), -}) - -type SignatureFormData = z.infer - -export function SignatureForm() { - const canvasRef = useRef(null) - const form = useForm({ - resolver: zodResolver(FormSchema), - }) - - const onSubmit = (data: SignatureFormData) => { - // console.log('Signature Data URL:', data.signature) - toast( -
-        {JSON.stringify(data, null, 2)}
-      
, - ) - } - - return ( -
- - ( - -
- Sign here -
- - - Please provide your signature above - - -
- )} - /> - - - - ) -} diff --git a/components/components/signature-input.tsx b/components/components/signature-input.tsx index 39c9ed9..2a718d4 100644 --- a/components/components/signature-input.tsx +++ b/components/components/signature-input.tsx @@ -25,9 +25,7 @@ export default function SignatureInputPreview() { return ( val !== null && val.length > 0, { - message: 'Please sign the form', - }), -}) - -type SignaturePadFormData = z.infer - -export function SignaturePadForm() { - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - signature: null, - }, - }) - - const onSubmit = (data: SignaturePadFormData) => { - // The signature is a base64 data URL that can be: - // 1. Stored directly in a database as a string - // 2. Converted to a Blob/File for upload to cloud storage - // 3. Sent to an API endpoint - - // Example: Convert to Blob for file upload - // const blob = await fetch(data.signature!).then(r => r.blob()) - // const file = new File([blob], 'signature.png', { type: 'image/png' }) - - toast( -
-

Signature captured!

-

- Data URL length: {data.signature?.length} characters -

- {data.signature && ( - // eslint-disable-next-line @next/next/no-img-element - Captured signature - )} -
, - ) - } - - return ( -
- - ( - - Your Signature - - - Click the pen button to open the signature pad. Draw your - signature, then hold the confirm button to save. - - - - )} - /> - - - - ) -} diff --git a/components/components/signature-pad.tsx b/components/components/signature-pad.tsx index 365dd79..31fdebd 100644 --- a/components/components/signature-pad.tsx +++ b/components/components/signature-pad.tsx @@ -19,9 +19,7 @@ export default function SignaturePadPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/smart-datetime-input.tsx b/components/components/smart-datetime-input.tsx new file mode 100644 index 0000000..978b61a --- /dev/null +++ b/components/components/smart-datetime-input.tsx @@ -0,0 +1,47 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { SmartDatetimeInput } from '@/components/ui/smart-datetime-input' + +const previewCode = `` + +const usageCode = `import { SmartDatetimeInput } from '@/components/ui/smart-datetime-input' + +const [date, setDate] = React.useState(null) + +${previewCode}` + +export default function SmartDatetimeInputPreview() { + const [date, setDate] = React.useState(null) + + return ( + + +

+ Value: {date ? date.toString() : '—'} +

+
+ } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Parses natural language with chrono-node.', + 'Calendar and time list for pointer users.', + 'Locale and 12/24-hour aware.', + ]} + /> + ) +} diff --git a/components/components/sortable-list-input.tsx b/components/components/sortable-list-input.tsx index 3c08849..b9e5d3d 100644 --- a/components/components/sortable-list-input.tsx +++ b/components/components/sortable-list-input.tsx @@ -15,9 +15,7 @@ export default function SortableListInputPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/tags-input.tsx b/components/components/tags-input.tsx new file mode 100644 index 0000000..112f69f --- /dev/null +++ b/components/components/tags-input.tsx @@ -0,0 +1,32 @@ +'use client' + +import * as React from 'react' + +import { ComponentDocShell } from '@/components/components/component-doc-shell' +import { TagsInput } from '@/components/ui/tags-input' + +const previewCode = `` + +const usageCode = `import { TagsInput } from '@/components/ui/tags-input' + +const [tags, setTags] = React.useState([]) + +${previewCode}` + +export default function TagsInputPreview() { + const [tags, setTags] = React.useState(['react', 'shadcn']) + + return ( + } + previewCode={previewCode} + usageCode={usageCode} + features={[ + 'Enter or comma adds a tag; Backspace removes the last one.', + 'Arrow keys move between tags.', + 'Optional minItems and maxItems.', + ]} + /> + ) +} diff --git a/components/components/token-input.tsx b/components/components/token-input.tsx index 1eb08dc..2260536 100644 --- a/components/components/token-input.tsx +++ b/components/components/token-input.tsx @@ -15,9 +15,7 @@ export default function TokenInputPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/transfer-list.tsx b/components/components/transfer-list.tsx index 82e0d4b..94d3d80 100644 --- a/components/components/transfer-list.tsx +++ b/components/components/transfer-list.tsx @@ -25,9 +25,7 @@ export default function TransferListPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/components/tree-select.tsx b/components/components/tree-select.tsx index d624817..f597bb7 100644 --- a/components/components/tree-select.tsx +++ b/components/components/tree-select.tsx @@ -34,9 +34,7 @@ export default function TreeSelectPreview() { return ( } previewCode={previewCode} usageCode={usageCode} diff --git a/components/editor/block-content.tsx b/components/editor/block-content.tsx deleted file mode 100644 index a956948..0000000 --- a/components/editor/block-content.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client' - -import { Input } from '@/components/ui/input' -import { EditorBlock, EditorColumn } from '@/types' -import { ColumnManager } from './column-manager' - -interface BlockContentProps { - block: EditorBlock - onChange: (content: string) => void - onKeyDown?: ( - e: React.KeyboardEvent, - columnId?: string, - ) => void - onColumnChange?: (columns: EditorColumn[]) => void -} - -export function BlockContent({ - block, - onChange, - onKeyDown, - onColumnChange, -}: BlockContentProps) { - if (block.type === 'columns' && block.columns) { - return ( - - ) - } - - return ( - onChange(e.target.value)} - onKeyDown={onKeyDown} - placeholder="Type '/' for commands..." - className="border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent" - /> - ) -} diff --git a/components/editor/column-manager.tsx b/components/editor/column-manager.tsx deleted file mode 100644 index 88531dc..0000000 --- a/components/editor/column-manager.tsx +++ /dev/null @@ -1,165 +0,0 @@ -'use client' - -import { Input } from '@/components/ui/input' -import { Plus, Minus } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' -import * as React from 'react' -import { EditorColumn } from '@/types' - -interface ColumnManagerProps { - blockId: string - columns: EditorColumn[] - onColumnChange?: (columns: EditorColumn[]) => void - onKeyDown?: ( - e: React.KeyboardEvent, - columnId: string, - ) => void -} - -export function ColumnManager({ - blockId, - columns, - onColumnChange, - onKeyDown, -}: ColumnManagerProps) { - const totalWidth = React.useMemo( - () => columns.reduce((acc, col) => acc + col.width, 0), - [columns], - ) - - const handleColumnChange = (newColumns: EditorColumn[]) => { - if (onColumnChange) { - onColumnChange(newColumns) - } - } - - const addColumn = () => { - if (totalWidth >= 12) return - const newColumn: EditorColumn = { - id: Math.random().toString(36).substr(2, 9), - content: '', - width: Math.min(12 - totalWidth, 6), - } - handleColumnChange([...columns, newColumn]) - } - - const removeColumn = (id: string) => { - if (columns.length <= 1) return - handleColumnChange(columns.filter((col) => col.id !== id)) - } - - const adjustWidth = (id: string, increment: boolean) => { - const newColumns = columns.map((col) => { - if (col.id === id) { - const newWidth = increment ? col.width + 1 : col.width - 1 - if (newWidth < 1 || newWidth > 12) return col - - const potentialTotalWidth = columns.reduce( - (acc, c) => (c.id === id ? acc + newWidth : acc + c.width), - 0, - ) - - if (potentialTotalWidth > 12) return col - - return { ...col, width: newWidth } - } - return col - }) - - handleColumnChange(newColumns) - } - - const updateColumnContent = (id: string, content: string) => { - const newColumns = columns.map((col) => - col.id === id ? { ...col, content } : col, - ) - handleColumnChange(newColumns) - } - - return ( -
-
- {columns.map((column) => ( -
-
-
- - {column.width} - - -
-
- - updateColumnContent(column.id, e.target.value)} - onKeyDown={(e) => onKeyDown && onKeyDown(e, column.id)} - placeholder="Type '/' for commands..." - className="border-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> -
- ))} -
- - - - - - - -

Maximum 12 columns width total

-
-
-
-
- ) -} diff --git a/components/editor/editor.tsx b/components/editor/editor.tsx deleted file mode 100644 index 281f57e..0000000 --- a/components/editor/editor.tsx +++ /dev/null @@ -1,256 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem as CommandListItem, - CommandList, -} from '@/components/ui/command' -import { GripVertical, MoreVertical } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Reorder, useDragControls } from 'framer-motion' -import { BlockContent } from './block-content' -import { EditorBlock, EditorColumn } from '@/types' -import { - labelCommands, - layoutCommands, - componentCommands, -} from '@/constants/menu' - -const iconSize = 'h-4 w-4' - -export default function Editor() { - const [blocks, setBlocks] = React.useState([ - { id: '1', type: 'text', content: '' }, - ]) - const [showCommand, setShowCommand] = React.useState(false) - const [activeBlockId, setActiveBlockId] = React.useState('1') - const dragControls = useDragControls() - - const handleKeyDown = ( - e: React.KeyboardEvent, - blockId: string, - columnId?: string, - ) => { - if (e.key === '/' && !showCommand) { - e.preventDefault() - setShowCommand(true) - setActiveBlockId(blockId) - } else if (e.key === 'Enter') { - e.preventDefault() - const newBlock: EditorBlock = { - id: Math.random().toString(36).substr(2, 9), - type: 'text', - content: '', - } - - setBlocks((prev) => { - const currentIndex = prev.findIndex((block) => block.id === blockId) - const newBlocks = [...prev] - newBlocks.splice(currentIndex + 1, 0, newBlock) - return newBlocks - }) - } - } - - const handleCommandSelect = (value: string) => { - const newBlock: EditorBlock = { - id: Math.random().toString(36).substr(2, 9), - type: value as EditorBlock['type'], - content: '', - ...(value === 'columns' - ? { - columns: [ - { - id: Math.random().toString(36).substr(2, 9), - content: '', - width: 6, - }, - { - id: Math.random().toString(36).substr(2, 9), - content: '', - width: 6, - }, - ], - } - : {}), - } - - setBlocks((prev) => { - const activeBlockIndex = prev.findIndex( - (block) => block.id === activeBlockId, - ) - const newBlocks = [...prev] - newBlocks.splice(activeBlockIndex + 1, 0, newBlock) - return newBlocks - }) - - setShowCommand(false) - } - - const handleContentChange = (id: string, content: string) => { - setBlocks((prev) => - prev.map((block) => (block.id === id ? { ...block, content } : block)), - ) - } - - const handleColumnChange = (blockId: string, columns: EditorColumn[]) => { - setBlocks((prev) => - prev.map((block) => - block.id === blockId ? { ...block, columns } : block, - ), - ) - } - - const duplicateBlock = (id: string) => { - const blockToDuplicate = blocks.find((block) => block.id === id) - if (!blockToDuplicate) return - - const newBlock: EditorBlock = { - ...blockToDuplicate, - id: Math.random().toString(36).substr(2, 9), - ...(blockToDuplicate.columns - ? { - columns: blockToDuplicate.columns.map((col) => ({ - ...col, - id: Math.random().toString(36).substr(2, 9), - })), - } - : {}), - } - - setBlocks((prev) => { - const index = prev.findIndex((block) => block.id === id) - const newBlocks = [...prev] - newBlocks.splice(index + 1, 0, newBlock) - return newBlocks - }) - } - - const removeBlock = (id: string) => { - setBlocks((prev) => { - if (prev.length <= 1) return prev - return prev.filter((block) => block.id !== id) - }) - } - - return ( -
- - {blocks.map((block) => ( - -
dragControls.start(e)} // Start dragging from the icon - > - -
-
- handleContentChange(block.id, content)} - onKeyDown={(e, columnId) => - handleKeyDown(e, block.id, columnId) - } - onColumnChange={(newColumns) => - handleColumnChange(block.id, newColumns) - } - /> -
- - - - - - duplicateBlock(block.id)} - className="flex items-center gap-2" - > - Duplicate - ⌘D - - removeBlock(block.id)} - className="flex items-center gap-2 text-red-600" - > - Remove - ⌫ - - - -
- ))} -
- - - - - No results found. - - {labelCommands.map((command) => ( - handleCommandSelect(command.value)} - > - {command.icon} - {command.title} - - ))} - - - {layoutCommands.map((command) => ( - handleCommandSelect(command.value)} - > - {command.icon} - {command.title} - - ))} - - - {componentCommands.map((component) => ( - handleCommandSelect(component.value)} - className={component.isNew ? 'font-bold' : ''} - > - {component.icon} - {component.title} - {component.isNew && ( - New - )} - - ))} - - - -
- ) -} diff --git a/components/features-vertical.tsx b/components/features-vertical.tsx index 3cbed3f..bb4b278 100644 --- a/components/features-vertical.tsx +++ b/components/features-vertical.tsx @@ -1,83 +1,12 @@ 'use client' -import * as Accordion from '@radix-ui/react-accordion' import { motion, useInView } from 'framer-motion' -import React, { - forwardRef, - ReactNode, - useEffect, - useRef, - useState, -} from 'react' +import React, { useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import Safari from './safari' import { Card, CardContent } from './ui/card' -type AccordionItemProps = { - children: React.ReactNode - className?: string -} & Accordion.AccordionItemProps - -const AccordionItem = forwardRef( - ({ children, className, ...props }, forwardedRef) => ( - - {children} - - ), -) -AccordionItem.displayName = 'AccordionItem' - -type AccordionTriggerProps = { - children: React.ReactNode - className?: string -} - -const AccordionTrigger = forwardRef( - ({ children, className, ...props }, forwardedRef) => ( - - - {children} - - - ), -) -AccordionTrigger.displayName = 'AccordionTrigger' -type AccordionContentProps = { - children: ReactNode - className?: string -} & Accordion.AccordionContentProps - -const AccordionContent = forwardRef( - ({ children, className, ...props }, forwardedRef) => ( - -
{children}
-
- ), -) -AccordionContent.displayName = 'AccordionContent' - export type FeaturesDataProps = { id: number title: string diff --git a/components/field-variants/checkbox.tsx b/components/field-variants/checkbox.tsx new file mode 100644 index 0000000..b6576fe --- /dev/null +++ b/components/field-variants/checkbox.tsx @@ -0,0 +1,38 @@ +'use client' + +import { Checkbox } from '@/components/ui/checkbox' +import { code, zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const checkboxVariant: FieldVariant = { + name: 'Checkbox', + defaults: { + label: 'Use different settings for my mobile devices', + description: 'You can manage your mobile notifications in the mobile settings page.', + }, + registryItems: ['checkbox'], + imports: () => ['import { Checkbox } from "@/components/ui/checkbox"'], + schema: (field) => + field.required + ? zx.boolean().refine(code((value: boolean) => value, '(value) => value'), { + message: 'Required', + }) + : zx.boolean(), + defaultValue: () => false, + layout: 'control-first', + Control: ({ field, id, value, onChange, invalid }) => ( + onChange(checked === true)} + aria-invalid={invalid} + /> + ), + control: (field, b) => ` ${b.onChange('checked === true')}} + aria-invalid={${b.invalid}} +/>`, +} diff --git a/components/field-variants/combobox.tsx b/components/field-variants/combobox.tsx new file mode 100644 index 0000000..caaa4c0 --- /dev/null +++ b/components/field-variants/combobox.tsx @@ -0,0 +1,131 @@ +'use client' + +import { Check, ChevronsUpDown } from 'lucide-react' + +import { buttonVariants } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { stringSchema } from './helpers' +import type { FieldVariant } from './types' + +const languages = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'German', value: 'de' }, + { label: 'Spanish', value: 'es' }, + { label: 'Portuguese', value: 'pt' }, + { label: 'Russian', value: 'ru' }, + { label: 'Japanese', value: 'ja' }, + { label: 'Korean', value: 'ko' }, + { label: 'Chinese', value: 'zh' }, +] as const + +export const comboboxVariant: FieldVariant = { + name: 'Combobox', + defaults: { + label: 'Language', + description: 'This is the language that will be used in the dashboard.', + }, + registryItems: ['button', 'command', 'popover'], + imports: () => [ + 'import { Check, ChevronsUpDown } from "lucide-react"', + 'import { cn } from "@/lib/utils"', + 'import { buttonVariants } from "@/components/ui/button"', + 'import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"', + 'import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"', + ], + declarations: [ + `const languages = [ +${languages.map((language) => ` { label: "${language.label}", value: "${language.value}" },`).join('\n')} +] as const`, + ], + schema: (field) => stringSchema(field, 'Please select a language'), + defaultValue: () => '', + Control: ({ field, id, value, onChange, invalid }) => ( + + + {languages.find((language) => language.value === value)?.label ?? 'Select language'} + + + + + + + No language found. + + {languages.map((language) => ( + onChange(language.value)} + > + + {language.label} + + ))} + + + + + + ), + control: (field, b) => ` + + {languages.find((language) => language.value === ${b.value})?.label ?? "Select language"} + + + + + + + No language found. + + {languages.map((language) => ( + ${b.onChange('language.value')}} + > + + {language.label} + + ))} + + + + +`, +} diff --git a/components/field-variants/credit-card.tsx b/components/field-variants/credit-card.tsx new file mode 100644 index 0000000..9b8a706 --- /dev/null +++ b/components/field-variants/credit-card.tsx @@ -0,0 +1,36 @@ +'use client' + +import { CreditCard } from '@/components/ui/credit-card' +import { code, zx } from './schema-expr' +import type { FieldVariant } from './types' + +const PARTS = ['cardholderName', 'cardNumber', 'expiryMonth', 'expiryYear', 'cvv'] as const + +export const creditCardVariant: FieldVariant = { + name: 'Credit Card', + defaults: { + label: 'Credit Card Information', + description: 'Enter your credit card details for payment.', + }, + registryItems: ['credit-card'], + imports: () => ['import { CreditCard } from "@/components/ui/credit-card"'], + schema: (field) => { + const card = zx.object(Object.fromEntries(PARTS.map((part) => [part, zx.string()]))) + // One message on the field itself, not one per card part. + return field.required + ? card.refine( + code( + (value: Record) => Object.values(value).every(Boolean), + '(card) => Object.values(card).every(Boolean)', + ), + { message: 'Please fill in all credit card fields' }, + ) + : card + }, + defaultValue: () => Object.fromEntries(PARTS.map((part) => [part, ''])), + Control: ({ value, onChange }) => , + control: (_field, b) => ` ${b.onChange('card')}} +/>`, +} diff --git a/components/field-variants/date-picker.tsx b/components/field-variants/date-picker.tsx new file mode 100644 index 0000000..481610a --- /dev/null +++ b/components/field-variants/date-picker.tsx @@ -0,0 +1,74 @@ +'use client' + +import { format } from 'date-fns' +import { CalendarIcon } from 'lucide-react' + +import { buttonVariants } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { optionalUnlessRequired } from './helpers' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const datePickerVariant: FieldVariant = { + name: 'Date Picker', + defaults: { + label: 'Date of birth', + description: 'Your date of birth is used to calculate your age.', + }, + registryItems: ['button', 'calendar', 'popover'], + imports: () => [ + 'import { format } from "date-fns"', + 'import { CalendarIcon } from "lucide-react"', + 'import { cn } from "@/lib/utils"', + 'import { buttonVariants } from "@/components/ui/button"', + 'import { Calendar } from "@/components/ui/calendar"', + 'import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"', + ], + schema: (field) => optionalUnlessRequired(field, zx.date({ message: 'A date is required' })), + defaultValue: () => new Date(), + Control: ({ field, id, value, onChange, invalid }) => ( + + + + {value ? format(value, 'PPP') : Pick a date} + + + + + + ), + control: (field, b) => ` + + + {${b.value} ? format(${b.value}, "PPP") : Pick a date} + + + date && ${b.onChange('date')}} + /> + +`, +} diff --git a/components/field-variants/datetime-picker.tsx b/components/field-variants/datetime-picker.tsx new file mode 100644 index 0000000..577b3ea --- /dev/null +++ b/components/field-variants/datetime-picker.tsx @@ -0,0 +1,36 @@ +'use client' + +import { DatetimePicker } from '@/components/ui/datetime-picker' +import { optionalUnlessRequired } from './helpers' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const datetimePickerVariant: FieldVariant = { + name: 'Datetime Picker', + defaults: { + label: 'Submission Date', + description: 'Add the date of submission with detailly.', + }, + registryItems: ['datetime-picker'], + imports: () => ['import { DatetimePicker } from "@/components/ui/datetime-picker"'], + schema: (field) => optionalUnlessRequired(field, zx.date({ message: 'A date is required' })), + defaultValue: () => new Date(), + Control: ({ value, onChange }) => ( + + ), + control: (_field, b) => ` date && ${b.onChange('date')}} + format={[ + ["months", "days", "years"], + ["hours", "minutes", "am/pm"], + ]} +/>`, +} diff --git a/components/field-variants/field-shell.tsx b/components/field-variants/field-shell.tsx new file mode 100644 index 0000000..bf46d77 --- /dev/null +++ b/components/field-variants/field-shell.tsx @@ -0,0 +1,117 @@ +'use client' + +import type { ReactNode } from 'react' + +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel, +} from '@/components/ui/field' +import type { FormFieldType } from '@/types' +import { getFieldVariant } from './index' + +/** + * How a field's label, control, description and error are arranged. The + * preview renders `FieldShell`; the generator prints `fieldShellSource`. + * Keep the two in step. + */ + +type ShellProps = { + field: FormFieldType + id: string + invalid: boolean + error?: { message?: string } + children: ReactNode +} + +export function FieldShell({ field, id, invalid, error, children }: ShellProps) { + const layout = getFieldVariant(field.variant).layout ?? 'stacked' + const label = ( + + {field.label} + {field.required && *} + + ) + const description = field.description ? {field.description} : null + const message = invalid ? : null + + if (layout === 'stacked') { + return ( + + {label} + {children} + {description} + {message} + + ) + } + + const content = ( + + {label} + {description} + {message} + + ) + return ( + + {layout === 'control-first' ? children : content} + {layout === 'control-first' ? content : children} + + ) +} + +type ShellSource = { + /** Expressions the form library provides. */ + id: string + invalid: string + /** The `` element, rendered only when invalid. */ + error: string +} + +const indent = (source: string, spaces: number) => + source + .split('\n') + .map((line) => (line ? ' '.repeat(spaces) + line : line)) + .join('\n') + +export function fieldShellSource(field: FormFieldType, s: ShellSource, control: string): string { + const layout = getFieldVariant(field.variant).layout ?? 'stacked' + const label = `${escapeText(field.label)}` + const description = field.description + ? `${escapeText(field.description)}` + : '' + const message = `{${s.invalid} && ${s.error}}` + + if (layout === 'stacked') { + return [ + ``, + indent([label, control, description, message].filter(Boolean).join('\n'), 2), + '', + ].join('\n') + } + + const content = [ + '', + indent([label, description, message].filter(Boolean).join('\n'), 2), + '', + ].join('\n') + const parts = layout === 'control-first' ? [control, content] : [content, control] + const className = layout === 'control-last' ? ' className="rounded-lg border p-4"' : '' + return [ + ``, + indent(parts.join('\n'), 2), + '', + ].join('\n') +} + +/** Text placed between JSX tags, with the characters JSX treats specially escaped. */ +function escapeText(text: string) { + return /[{}<>]/.test(text) ? `{${JSON.stringify(text)}}` : text +} diff --git a/components/field-variants/file-input.tsx b/components/field-variants/file-input.tsx new file mode 100644 index 0000000..b8fd06f --- /dev/null +++ b/components/field-variants/file-input.tsx @@ -0,0 +1,80 @@ +'use client' + +import { CloudUpload, Paperclip } from 'lucide-react' + +import { + FileInput, + FileUploader, + FileUploaderContent, + FileUploaderItem, +} from '@/components/ui/file-upload' +import { code, zx } from './schema-expr' +import type { FieldVariant } from './types' + +const dropzoneOptions = { maxFiles: 5, maxSize: 1024 * 1024 * 4, multiple: true } + +export const fileInputVariant: FieldVariant = { + name: 'File Input', + defaults: { label: 'Select File', description: 'Select a file to upload.' }, + registryItems: ['file-upload'], + imports: () => [ + 'import { CloudUpload, Paperclip } from "lucide-react"', + 'import { FileInput, FileUploader, FileUploaderContent, FileUploaderItem } from "@/components/ui/file-upload"', + ], + declarations: ['const dropzoneOptions = { maxFiles: 5, maxSize: 1024 * 1024 * 4, multiple: true }'], + schema: (field) => { + const files = zx.array(zx.instanceof(code(File, 'File'))) + return field.required ? files.min(1, { message: 'Select at least one file' }) : files + }, + defaultValue: () => [], + Control: ({ value, onChange }) => ( + onChange(files ?? [])} + dropzoneOptions={dropzoneOptions} + className="relative rounded-lg bg-background p-2" + > + +
+ +

+ Click to upload or drag and drop +

+

SVG, PNG, JPG or GIF

+
+
+ + {(value as File[] | undefined)?.map((file, index) => ( + + + {file.name} + + ))} + +
+ ), + control: (_field, b) => ` ${b.onChange('files ?? []')}} + dropzoneOptions={dropzoneOptions} + className="relative rounded-lg bg-background p-2" +> + +
+ +

+ Click to upload or drag and drop +

+

SVG, PNG, JPG or GIF

+
+
+ + {${b.value}?.map((file, index) => ( + + + {file.name} + + ))} + +
`, +} diff --git a/components/field-variants/form.ts b/components/field-variants/form.ts new file mode 100644 index 0000000..ebb05f8 --- /dev/null +++ b/components/field-variants/form.ts @@ -0,0 +1,56 @@ +import type { z } from 'zod' + +import type { FormFieldType } from '@/types' +import { getFieldVariant } from './index' +import { printValue, toSource, toZod, zx } from './schema-expr' + +/** A playground form: fields, or rows of up to three fields shown side by side. */ +export type FormFieldOrGroup = FormFieldType | FormFieldType[] + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/ +const key = (name: string) => (IDENTIFIER.test(name) ? name : JSON.stringify(name)) + +/** A new field of `variant` with that variant's default label, description and placeholder. */ +export function createField(variant: string): FormFieldType { + const { defaults } = getFieldVariant(variant) + return { + variant, + name: `name_${Math.random().toString().slice(-10)}`, + label: defaults.label, + description: defaults.description, + placeholder: defaults.placeholder ?? '', + required: true, + disabled: false, + } +} + +/** The runtime zod schema the playground preview validates with. */ +export function buildFormSchema(fields: FormFieldOrGroup[]): z.ZodObject { + return toZod( + zx.object( + Object.fromEntries(fields.flat().map((field) => [field.name, getFieldVariant(field.variant).schema(field)])), + ), + ) as z.ZodObject +} + +/** `const formSchema = z.object({...})`, the same schema as source code. */ +export function formSchemaSource(fields: FormFieldOrGroup[]): string { + const entries = fields + .flat() + .map((field) => ` ${key(field.name)}: ${toSource(getFieldVariant(field.variant).schema(field))},`) + return `const formSchema = z.object({\n${entries.join('\n')}\n})` +} + +export function formDefaultValues(fields: FormFieldOrGroup[]): Record { + return Object.fromEntries( + fields.flat().map((field) => [field.name, getFieldVariant(field.variant).defaultValue(field)]), + ) +} + +/** The default values object as source code, e.g. `{ name: "", birthday: new Date() }`. */ +export function formDefaultValuesSource(fields: FormFieldOrGroup[]): string { + const entries = fields + .flat() + .map((field) => `${key(field.name)}: ${printValue(getFieldVariant(field.variant).defaultValue(field))},`) + return `{\n${entries.map((entry) => ` ${entry}`).join('\n')}\n}` +} diff --git a/components/field-variants/helpers.ts b/components/field-variants/helpers.ts new file mode 100644 index 0000000..b6751ae --- /dev/null +++ b/components/field-variants/helpers.ts @@ -0,0 +1,25 @@ +import type { FormFieldType } from '@/types' +import { zx, type SchemaExpr } from './schema-expr' + +/** A JSX attribute value for a string: `"text"`, or `{"..."}` when it needs escaping. */ +export const lit = (value: string | undefined) => { + const text = value ?? '' + return /["\\{}\n]/.test(text) ? `{${JSON.stringify(text)}}` : `"${text}"` +} + +/** `required` string: at least `min` (default 1) characters; optional strings may be empty. */ +export function stringSchema(field: FormFieldType, message = 'Required'): SchemaExpr { + let schema = zx.string() + if (field.required) { + schema = field.min + ? schema.min(field.min, { message: `Must be at least ${field.min} characters` }) + : schema.min(1, { message }) + } + if (field.max) schema = schema.max(field.max, { message: `Must be at most ${field.max} characters` }) + return schema +} + +/** Marks a non-string schema optional unless the field is required. */ +export function optionalUnlessRequired(field: FormFieldType, schema: SchemaExpr): SchemaExpr { + return field.required ? schema : (schema as ReturnType).optional() +} diff --git a/components/field-variants/index.ts b/components/field-variants/index.ts new file mode 100644 index 0000000..7d2a21a --- /dev/null +++ b/components/field-variants/index.ts @@ -0,0 +1,59 @@ +import { checkboxVariant } from './checkbox' +import { comboboxVariant } from './combobox' +import { creditCardVariant } from './credit-card' +import { datePickerVariant } from './date-picker' +import { datetimePickerVariant } from './datetime-picker' +import { fileInputVariant } from './file-input' +import { inputVariant } from './input' +import { inputOtpVariant } from './input-otp' +import { locationInputVariant } from './location-input' +import { multiSelectVariant } from './multi-select' +import { passwordVariant } from './password' +import { phoneVariant } from './phone' +import { radioGroupVariant } from './radio-group' +import { ratingVariant } from './rating' +import { selectVariant } from './select' +import { signatureInputVariant } from './signature-input' +import { signaturePadVariant } from './signature-pad' +import { sliderVariant } from './slider' +import { smartDatetimeInputVariant } from './smart-datetime-input' +import { switchVariant } from './switch' +import { tagsInputVariant } from './tags-input' +import { textareaVariant } from './textarea' +import type { FieldVariant } from './types' + +export type { Binding, ControlProps, FieldSetting, FieldVariant } from './types' + +/** Every field the playground offers, in picker order. */ +export const fieldVariants: FieldVariant[] = [ + checkboxVariant, + comboboxVariant, + datePickerVariant, + datetimePickerVariant, + fileInputVariant, + inputVariant, + inputOtpVariant, + locationInputVariant, + multiSelectVariant, + passwordVariant, + phoneVariant, + selectVariant, + signatureInputVariant, + signaturePadVariant, + sliderVariant, + smartDatetimeInputVariant, + switchVariant, + tagsInputVariant, + textareaVariant, + ratingVariant, + radioGroupVariant, + creditCardVariant, +] + +const byName = new Map(fieldVariants.map((variant) => [variant.name, variant])) + +export function getFieldVariant(name: string): FieldVariant { + const variant = byName.get(name) + if (!variant) throw new Error(`Unknown field variant: ${name}`) + return variant +} diff --git a/components/field-variants/input-otp.tsx b/components/field-variants/input-otp.tsx new file mode 100644 index 0000000..fec7ba6 --- /dev/null +++ b/components/field-variants/input-otp.tsx @@ -0,0 +1,61 @@ +'use client' + +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from '@/components/ui/input-otp' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const inputOtpVariant: FieldVariant = { + name: 'Input OTP', + defaults: { + label: 'One-Time Password', + description: 'Please enter the one-time password sent to your phone.', + }, + registryItems: ['input-otp'], + imports: () => [ + 'import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"', + ], + schema: (field) => + field.required + ? zx.string().length(6, { message: 'Enter all 6 digits' }) + : zx.string().length(6, { message: 'Enter all 6 digits' }).or(zx.literal('')), + defaultValue: () => '', + Control: ({ field, id, value, onChange, onBlur }) => ( + + + + + + + + + + + + + + ), + control: (field, b) => ` ${b.onChange('value')}} + onBlur={${b.onBlur}} +> + + + + + + + + + + + +`, +} diff --git a/components/field-variants/input.tsx b/components/field-variants/input.tsx new file mode 100644 index 0000000..c47db59 --- /dev/null +++ b/components/field-variants/input.tsx @@ -0,0 +1,55 @@ +'use client' + +import { Input } from '@/components/ui/input' +import { lit, stringSchema } from './helpers' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const inputVariant: FieldVariant = { + name: 'Input', + defaults: { + label: 'Username', + description: 'This is your public display name.', + placeholder: 'shadcn', + }, + settings: ['inputType', 'range'], + registryItems: ['input'], + imports: () => ['import { Input } from "@/components/ui/input"'], + schema: (field) => { + if (field.type === 'email') { + const email = zx.email({ message: 'Invalid email address' }) + return field.required ? email : email.or(zx.literal('')) + } + if (field.type === 'number') { + // The input holds a string; the schema outputs a number. + let number = (zx.coerce as any)['number']({ message: 'Must be a number' }) + if (field.min !== undefined) number = number.min(field.min, { message: `Must be at least ${field.min}` }) + if (field.max !== undefined) number = number.max(field.max, { message: `Must be at most ${field.max}` }) + return number + } + return stringSchema(field) + }, + defaultValue: () => '', + Control: ({ field, id, value, onChange, onBlur, invalid }) => ( + onChange(event.target.value)} + onBlur={onBlur} + aria-invalid={invalid} + /> + ), + control: (field, b) => ` ${b.onChange('event.target.value')}} + onBlur={${b.onBlur}} + aria-invalid={${b.invalid}} +/>`, +} diff --git a/components/field-variants/location-input.tsx b/components/field-variants/location-input.tsx new file mode 100644 index 0000000..58c459c --- /dev/null +++ b/components/field-variants/location-input.tsx @@ -0,0 +1,38 @@ +'use client' + +import LocationSelector from '@/components/ui/location-input' +import { code, zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const locationInputVariant: FieldVariant = { + name: 'Location Input', + defaults: { + label: 'Select Country', + description: 'If your country has states, it will be appear after selecting country', + }, + registryItems: ['location-input'], + imports: () => ['import LocationSelector from "@/components/ui/location-input"'], + schema: (field) => { + const location = zx.tuple([zx.string(), zx.string().optional()]) + // Reported on the field itself rather than on the tuple's first element. + return field.required + ? location.refine(code(([country]: [string, string?]) => country.length > 0, '([country]) => country.length > 0'), { + message: 'Country is required', + }) + : location + }, + defaultValue: () => ['', ''], + Control: ({ field, value, onChange }) => ( + onChange([country?.name ?? '', ''])} + onStateChange={(state) => onChange([value?.[0] ?? '', state?.name ?? ''])} + /> + ), + control: (field, b) => ` ${b.onChange('[country?.name ?? "", ""] as [string, string]')}} + onStateChange={(state) => + ${b.onChange(`[${b.value}[0], state?.name ?? ""] as [string, string]`)} + } +/>`, +} diff --git a/components/field-variants/multi-select.tsx b/components/field-variants/multi-select.tsx new file mode 100644 index 0000000..38a14b0 --- /dev/null +++ b/components/field-variants/multi-select.tsx @@ -0,0 +1,59 @@ +'use client' + +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from '@/components/ui/multi-select' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +const OPTIONS = ['React', 'Vue', 'Svelte'] + +export const multiSelectVariant: FieldVariant = { + name: 'Multi Select', + defaults: { label: 'Select your framework', description: 'Select multiple options.' }, + registryItems: ['multi-select'], + imports: () => [ + 'import { MultiSelector, MultiSelectorContent, MultiSelectorInput, MultiSelectorItem, MultiSelectorList, MultiSelectorTrigger } from "@/components/ui/multi-select"', + ], + schema: (field) => { + const values = zx.array(zx.string()) + return field.required ? values.min(1, { message: 'Please select at least one item' }) : values + }, + defaultValue: () => [], + Control: ({ value, onChange }) => ( + + + + + + + {OPTIONS.map((option) => ( + + {option} + + ))} + + + + ), + control: (_field, b) => ` ${b.onChange('values')}} + loop + className="max-w-xs" +> + + + + + +${OPTIONS.map((option) => ` ${option}`).join('\n')} + + +`, +} diff --git a/components/field-variants/password.tsx b/components/field-variants/password.tsx new file mode 100644 index 0000000..d16d50c --- /dev/null +++ b/components/field-variants/password.tsx @@ -0,0 +1,34 @@ +'use client' + +import { PasswordInput } from '@/components/ui/password-input' +import { lit, stringSchema } from './helpers' +import type { FieldVariant } from './types' + +export const passwordVariant: FieldVariant = { + name: 'Password', + defaults: { label: 'Password', description: 'Enter your password.', placeholder: '********' }, + settings: ['range'], + registryItems: ['password-input'], + imports: () => ['import { PasswordInput } from "@/components/ui/password-input"'], + schema: (field) => stringSchema(field), + defaultValue: () => '', + Control: ({ field, id, value, onChange, onBlur, invalid }) => ( + onChange(event.target.value)} + onBlur={onBlur} + aria-invalid={invalid} + /> + ), + control: (field, b) => ` ${b.onChange('event.target.value')}} + onBlur={${b.onBlur}} + aria-invalid={${b.invalid}} +/>`, +} diff --git a/components/field-variants/phone.tsx b/components/field-variants/phone.tsx new file mode 100644 index 0000000..84bc5dc --- /dev/null +++ b/components/field-variants/phone.tsx @@ -0,0 +1,44 @@ +'use client' + +import { isValidPhoneNumber } from 'react-phone-number-input' + +import { PhoneInput } from '@/components/ui/phone-input' +import { lit, optionalUnlessRequired } from './helpers' +import { code, zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const phoneVariant: FieldVariant = { + name: 'Phone', + defaults: { label: 'Phone number', description: 'Enter your phone number.', placeholder: 'Enter a phone number' }, + registryItems: ['phone-input'], + imports: () => [ + 'import { isValidPhoneNumber } from "react-phone-number-input"', + 'import { PhoneInput } from "@/components/ui/phone-input"', + ], + schema: (field) => + field.required + ? zx.string().refine(code(isValidPhoneNumber, 'isValidPhoneNumber'), { + message: 'Invalid phone number', + }) + : optionalUnlessRequired(field, zx.string()), + defaultValue: () => '', + Control: ({ field, id, value, onChange, onBlur }) => ( + onChange(phone ?? '')} + onBlur={onBlur} + /> + ), + control: (field, b) => ` ${b.onChange('phone ?? ""')}} + onBlur={${b.onBlur}} +/>`, +} diff --git a/components/field-variants/radio-group.tsx b/components/field-variants/radio-group.tsx new file mode 100644 index 0000000..34291cd --- /dev/null +++ b/components/field-variants/radio-group.tsx @@ -0,0 +1,52 @@ +'use client' + +import { FieldLabel } from '@/components/ui/field' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { stringSchema } from './helpers' +import type { FieldVariant } from './types' + +const OPTIONS = [ + { label: 'Male', value: 'male' }, + { label: 'Female', value: 'female' }, + { label: 'Other', value: 'other' }, +] + +export const radioGroupVariant: FieldVariant = { + name: 'RadioGroup', + defaults: { label: 'Gender', description: 'Select your gender' }, + registryItems: ['radio-group'], + imports: () => ['import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"'], + schema: (field) => stringSchema(field, 'Please select an option'), + defaultValue: () => '', + Control: ({ field, id, value, onChange, invalid }) => ( + onChange(String(next ?? ''))} + disabled={field.disabled} + aria-invalid={invalid} + > + {OPTIONS.map((option) => ( +
+ + + {option.label} + +
+ ))} +
+ ), + control: (field, b) => ` ${b.onChange('String(value ?? "")')}}${field.disabled ? '\n disabled' : ''} + aria-invalid={${b.invalid}} +> +${OPTIONS.map( + (option) => `
+ + + ${option.label} + +
`, +).join('\n')} +
`, +} diff --git a/components/field-variants/rating.tsx b/components/field-variants/rating.tsx new file mode 100644 index 0000000..910f9cf --- /dev/null +++ b/components/field-variants/rating.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Rating } from '@/components/ui/rating' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const ratingVariant: FieldVariant = { + name: 'Rating', + defaults: { label: 'Rating', description: 'Please provide your rating.' }, + registryItems: ['rating'], + imports: () => ['import { Rating } from "@/components/ui/rating"'], + schema: (field) => + field.required ? zx.number().min(1, { message: 'Please provide a rating' }) : zx.number(), + defaultValue: () => 0, + Control: ({ field, value, onChange }) => ( + + ), + control: (field, b) => ` ${b.onChange('value')}}${field.disabled ? '\n readOnly' : ''} +/>`, +} diff --git a/components/field-variants/schema-expr.ts b/components/field-variants/schema-expr.ts new file mode 100644 index 0000000..e2cdcb5 --- /dev/null +++ b/components/field-variants/schema-expr.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' + +/** + * A zod expression written once and read two ways: as the runtime schema + * the playground validates with, and as the source code the generator + * prints. `zx` records property reads and calls instead of running them, + * so `zx.string().min(1)` becomes both `z.string().min(1)` (runtime) and + * the text "z.string().min(1)". + * + * `zx` is typed as `typeof z` for autocompletion; what it returns are + * recordings, only meaningful to `toZod` and `toSource`. A property named + * with a type argument, `zx.coerce['number']`, prints the type + * argument and ignores it at runtime. + */ + +type Step = { kind: 'get'; name: string } | { kind: 'call'; args: unknown[] } + +const STEPS = Symbol('schema-expr steps') +const CODE = Symbol('schema-expr code') + +type Recording = { [STEPS]: Step[] } + +/** A value with a runtime form and a hand-written source form, e.g. a refine callback. */ +export type Code = { [CODE]: string; value: T } + +/** Typed as the value so it fits where zod expects it, e.g. `.refine(code(fn, 'fn'))`. */ +export function code(value: T, source: string): T { + return { [CODE]: source, value } as Code as unknown as T +} + +function record(steps: Step[]): unknown { + const target = function () {} as unknown as Recording + return new Proxy(target, { + get(_, prop) { + if (prop === STEPS) return steps + if (typeof prop === 'symbol' || prop === 'then') return undefined + return record([...steps, { kind: 'get', name: prop }]) + }, + apply(_, __, args) { + return record([...steps, { kind: 'call', args }]) + }, + }) +} + +export const zx = record([]) as typeof z + +export type SchemaExpr = unknown + +function stepsOf(expr: SchemaExpr): Step[] | undefined { + return typeof expr === 'function' ? (expr as unknown as Recording)[STEPS] : undefined +} + +function isCode(value: unknown): value is Code { + return typeof value === 'object' && value !== null && CODE in value +} + +function runtimeArg(arg: unknown): unknown { + if (stepsOf(arg)) return toZod(arg) + if (isCode(arg)) return arg.value + if (Array.isArray(arg)) return arg.map(runtimeArg) + if (arg && typeof arg === 'object' && !(arg instanceof Date)) { + return Object.fromEntries(Object.entries(arg).map(([k, v]) => [k, runtimeArg(v)])) + } + return arg +} + +export function toZod(expr: SchemaExpr): z.ZodType { + const steps = stepsOf(expr) + if (!steps) throw new Error('Not a schema expression') + let receiver: unknown = undefined + let current: unknown = z + for (const step of steps) { + if (step.kind === 'get') { + receiver = current + // `zx.coerce['number']` records a type argument for the source only. + current = (current as Record)[step.name.replace(/<.*>$/, '')] + } else { + current = (current as (...args: unknown[]) => unknown).apply(receiver, step.args.map(runtimeArg)) + receiver = undefined + } + } + return current as z.ZodType +} + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/ + +/** Prints a JS value as source: dates as `new Date()`, identifier keys unquoted. */ +export function printValue(value: unknown): string { + if (stepsOf(value)) return toSource(value) + if (isCode(value)) return value[CODE] + if (typeof value === 'function') { + throw new Error(`Wrap ${value.name || 'functions'} in code() so it has a source form`) + } + if (value instanceof Date) return 'new Date()' + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return `[${value.map(printValue).join(', ')}]` + if (value && typeof value === 'object') { + const entries = Object.entries(value).map( + ([key, v]) => `${IDENTIFIER.test(key) ? key : JSON.stringify(key)}: ${printValue(v)}`, + ) + return entries.length ? `{ ${entries.join(', ')} }` : '{}' + } + return JSON.stringify(value) +} + +export function toSource(expr: SchemaExpr): string { + const steps = stepsOf(expr) + if (!steps) throw new Error('Not a schema expression') + return steps.reduce( + (source, step) => + step.kind === 'get' + ? `${source}.${step.name}` + : `${source}(${step.args.map(printValue).join(', ')})`, + 'z', + ) +} diff --git a/components/field-variants/select.tsx b/components/field-variants/select.tsx new file mode 100644 index 0000000..5ef84cd --- /dev/null +++ b/components/field-variants/select.tsx @@ -0,0 +1,53 @@ +'use client' + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { lit, stringSchema } from './helpers' +import type { FieldVariant } from './types' + +const OPTIONS = ['m@example.com', 'm@google.com', 'm@support.com'] + +export const selectVariant: FieldVariant = { + name: 'Select', + defaults: { + label: 'Email', + description: 'You can manage email addresses in your email settings.', + placeholder: 'Select a verified email to display', + }, + registryItems: ['select'], + imports: () => [ + 'import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"', + ], + schema: (field) => stringSchema(field, 'Please select an option'), + defaultValue: () => '', + Control: ({ field, id, value, onChange, invalid }) => ( + + ), + control: (field, b) => ``, +} diff --git a/components/field-variants/signature-input.tsx b/components/field-variants/signature-input.tsx new file mode 100644 index 0000000..abc415d --- /dev/null +++ b/components/field-variants/signature-input.tsx @@ -0,0 +1,20 @@ +'use client' + +import SignatureInput from '@/components/ui/signature-input' +import { stringSchema } from './helpers' +import type { FieldVariant } from './types' + +export const signatureInputVariant: FieldVariant = { + name: 'Signature Input', + defaults: { label: 'Sign here', description: 'Please provide your signature above' }, + registryItems: ['signature-input'], + imports: () => ['import SignatureInput from "@/components/ui/signature-input"'], + schema: (field) => stringSchema(field, 'Signature is required'), + defaultValue: () => '', + Control: ({ onChange }) => ( + onChange(signature ?? '')} /> + ), + control: (_field, b) => ` ${b.onChange('signature ?? ""')}} +/>`, +} diff --git a/components/field-variants/signature-pad.tsx b/components/field-variants/signature-pad.tsx new file mode 100644 index 0000000..e73f4a3 --- /dev/null +++ b/components/field-variants/signature-pad.tsx @@ -0,0 +1,22 @@ +'use client' + +import SignaturePad from '@/components/ui/signature-pad' +import { stringSchema } from './helpers' +import type { FieldVariant } from './types' + +export const signaturePadVariant: FieldVariant = { + name: 'Signature Pad', + isNew: true, + defaults: { label: 'Your Signature', description: 'Click the pen button to sign' }, + registryItems: ['signature-pad'], + imports: () => ['import SignaturePad from "@/components/ui/signature-pad"'], + schema: (field) => stringSchema(field, 'Signature is required'), + defaultValue: () => '', + Control: ({ value, onChange }) => ( + onChange(signature ?? '')} /> + ), + control: (_field, b) => ` ${b.onChange('signature ?? ""')}} +/>`, +} diff --git a/components/field-variants/slider.tsx b/components/field-variants/slider.tsx new file mode 100644 index 0000000..cf44ada --- /dev/null +++ b/components/field-variants/slider.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Slider } from '@/components/ui/slider' +import type { FormFieldType } from '@/types' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +const range = (field: FormFieldType) => ({ + min: field.min ?? 0, + max: field.max ?? 100, + step: field.step ?? 1, +}) + +export const sliderVariant: FieldVariant = { + name: 'Slider', + defaults: { label: 'Set Price Range', description: 'Adjust the price by sliding.' }, + settings: ['range', 'step'], + registryItems: ['slider'], + imports: () => ['import { Slider } from "@/components/ui/slider"'], + schema: (field) => { + const { min, max } = range(field) + return zx + .number() + .min(min, { message: `Must be at least ${min}` }) + .max(max, { message: `Must be at most ${max}` }) + }, + defaultValue: (field) => range(field).min, + Control: ({ field, id, value, onChange }) => { + const { min, max, step } = range(field) + return ( +
+ + onChange(Array.isArray(next) ? next[0] : next) + } + /> +

Selected value: {value ?? min}

+
+ ) + }, + control: (field, b) => { + const { min, max, step } = range(field) + return ` + ${b.onChange('Array.isArray(value) ? value[0] : value')} + } +/>` + }, +} diff --git a/components/field-variants/smart-datetime-input.tsx b/components/field-variants/smart-datetime-input.tsx new file mode 100644 index 0000000..8a17226 --- /dev/null +++ b/components/field-variants/smart-datetime-input.tsx @@ -0,0 +1,39 @@ +'use client' + +import * as Locales from 'date-fns/locale' + +import { SmartDatetimeInput } from '@/components/ui/smart-datetime-input' +import { lit, optionalUnlessRequired } from './helpers' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const smartDatetimeInputVariant: FieldVariant = { + name: 'Smart Datetime Input', + defaults: { + label: "What's the best time for you?", + description: 'Please select the full time', + placeholder: 'e.g. Tomorrow morning 9am', + }, + settings: ['locale'], + registryItems: ['smart-datetime-input'], + imports: (field) => [ + 'import { SmartDatetimeInput } from "@/components/ui/smart-datetime-input"', + ...(field.locale ? [`import { ${field.locale} } from "date-fns/locale"`] : []), + ], + schema: (field) => optionalUnlessRequired(field, zx.date({ message: 'A date is required' })), + defaultValue: () => new Date(), + Control: ({ field, value, onChange }) => ( + + ), + control: (field, b) => ` date && ${b.onChange('date')}} + placeholder=${lit(field.placeholder)}${field.locale ? `\n locale={${field.locale}}` : ''}${field.hour12 ? '\n hour12' : ''} +/>`, +} diff --git a/components/field-variants/switch.tsx b/components/field-variants/switch.tsx new file mode 100644 index 0000000..22a8069 --- /dev/null +++ b/components/field-variants/switch.tsx @@ -0,0 +1,33 @@ +'use client' + +import { Switch } from '@/components/ui/switch' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const switchVariant: FieldVariant = { + name: 'Switch', + defaults: { + label: 'Marketing emails', + description: 'Receive emails about new products, features, and more.', + }, + registryItems: ['switch'], + imports: () => ['import { Switch } from "@/components/ui/switch"'], + schema: () => zx.boolean(), + defaultValue: () => false, + layout: 'control-last', + Control: ({ field, id, value, onChange, invalid }) => ( + onChange(checked === true)} + aria-invalid={invalid} + /> + ), + control: (field, b) => ` ${b.onChange('checked === true')}} + aria-invalid={${b.invalid}} +/>`, +} diff --git a/components/field-variants/tags-input.tsx b/components/field-variants/tags-input.tsx new file mode 100644 index 0000000..d93fd25 --- /dev/null +++ b/components/field-variants/tags-input.tsx @@ -0,0 +1,26 @@ +'use client' + +import { TagsInput } from '@/components/ui/tags-input' +import { lit } from './helpers' +import { zx } from './schema-expr' +import type { FieldVariant } from './types' + +export const tagsInputVariant: FieldVariant = { + name: 'Tags Input', + defaults: { label: 'Enter your tech stack.', description: 'Add tags.', placeholder: 'Enter your tags' }, + registryItems: ['tags-input'], + imports: () => ['import { TagsInput } from "@/components/ui/tags-input"'], + schema: (field) => { + const tags = zx.array(zx.string()) + return field.required ? tags.min(1, { message: 'Please enter at least one item' }) : tags + }, + defaultValue: () => [], + Control: ({ field, value, onChange }) => ( + + ), + control: (field, b) => ` ${b.onChange('tags')}} + placeholder=${lit(field.placeholder)} +/>`, +} diff --git a/components/field-variants/textarea.tsx b/components/field-variants/textarea.tsx new file mode 100644 index 0000000..3854787 --- /dev/null +++ b/components/field-variants/textarea.tsx @@ -0,0 +1,40 @@ +'use client' + +import { Textarea } from '@/components/ui/textarea' +import { lit, stringSchema } from './helpers' +import type { FieldVariant } from './types' + +export const textareaVariant: FieldVariant = { + name: 'Textarea', + defaults: { + label: 'Bio', + description: 'You can @mention other users and organizations.', + placeholder: 'Tell us a little bit about yourself', + }, + settings: ['range'], + registryItems: ['textarea'], + imports: () => ['import { Textarea } from "@/components/ui/textarea"'], + schema: (field) => stringSchema(field), + defaultValue: () => '', + Control: ({ field, id, value, onChange, onBlur, invalid }) => ( +