FormFrame

Show a form as a page, a dialog or an expandable screen. Every form follows lib/form-config.ts unless you pass a prop.

Installation
bunx --bun shadcn@latest add @fieldkit/form-frame

Needs the @fieldkit registry. Add it once

Usage
Terminal
import { FormFrame } from "@/components/form/form-frame"
<FormFrame title="New member" trigger={<Button>Add member</Button>}>  <Form schema={schema} defaultValues={defaults} onSubmit={save}>    ...  </Form></FormFrame>
// Override the defaults for one form<FormFrame title="New member" display="dialog" trigger={<Button>Add member</Button>}>...</FormFrame><JsonForm spec={spec} display="screen" trigger={<Button>Add member</Button>} onSubmit={save} />
// Set the defaults for every form inside, such as a route or a settings areaimport { FormDefaultsProvider } from "@/components/form/form-layout"
<FormDefaultsProvider display="dialog" widths={{ dialog: "md" }}>  <Outlet /></FormDefaultsProvider>
// Order: prop, then spec, then the nearest FormDefaultsProvider, then formConfig
// lib/form-config.ts sets the default for every formexport const formConfig: FormConfig = {  display: "page",  layout: "card",  widths: { page: "md", dialog: "sm", screen: "md" },  spacing: 3,  validateOn: "onBlur",  editMethod: "patch",  submit: {    progressToast: true,    uploadList: "collapsed",    messages: {      loading: "Saving",      success: "Saved",      editSuccess: "Changes saved",      error: "Couldn't save",      retry: "Retry",    },  },}
// Source"use client"
import { useId, useState } from "react"import type { CSSProperties } from "react"
import { FormCloseContext } from "@/components/form/form"import { FormLayout, useFormDefaults } from "@/components/form/form-layout"import { Button } from "@/components/ui/button"import {  Dialog,  DialogContent,  DialogDescription,  DialogHeader,  DialogTitle,  DialogTrigger,} from "@/components/ui/dialog"import {  ExpandableScreen,  ExpandableScreenContent,  ExpandableScreenHeader,  ExpandableScreenTitle,  ExpandableScreenTrigger,} from "@/components/ui/expandable-screen"import { formSpacing } from "@/lib/form-config"import type { FormDisplay, FormWidth } from "@/lib/form-config"
export type FormFrameProps = {  display?: FormDisplay  width?: FormWidth  card?: boolean  title?: React.ReactNode  description?: React.ReactNode  trigger?: React.ReactElement  children: React.ReactNode}
export function FormFrame({  display,  width,  card,  title,  description,  trigger,  children,}: FormFrameProps) {  const [open, setOpen] = useState(false)  const layoutId = useId()  const close = () => setOpen(false)  const opener = trigger ?? <Button>{title}</Button>  const defaults = useFormDefaults()  const shown = display ?? defaults.display  const size = width ?? defaults.widths[shown]
  if (shown === "page") {    return (      <FormLayout        title={title}        description={description}        width={size}        card={card}      >        {children}      </FormLayout>    )  }
  if (shown === "dialog") {    return (      <Dialog open={open} onOpenChange={setOpen}>        <DialogTrigger render={opener} />        <DialogContent          style={            { "--dialog-max-height": "min(40rem, 80dvh)" } as CSSProperties          }          data-size={size}          className="flex max-h-(--dialog-max-height) flex-col sm:max-w-md sm:data-[size=lg]:max-w-4xl sm:data-[size=md]:max-w-2xl"        >          <div className="shrink-0 pr-8">            <DialogHeader>              <DialogTitle>{title}</DialogTitle>              {description && (                <DialogDescription>{description}</DialogDescription>              )}            </DialogHeader>          </div>          <div className="-mx-6 -mt-1 -mb-6 min-h-0 scroll-fade-y overflow-y-auto overscroll-contain px-6 pt-1 pb-6">            <FormCloseContext.Provider value={close}>              {children}            </FormCloseContext.Provider>          </div>        </DialogContent>      </Dialog>    )  }
  return (    <ExpandableScreen      layoutId={layoutId}      expanded={open}      onExpandChange={setOpen}    >      <div        data-form-size={size}        style={{ "--screen-max-height": "min(40rem, 80dvh)" } as CSSProperties}        className="contents"      >        <ExpandableScreenTrigger>{opener}</ExpandableScreenTrigger>        <ExpandableScreenContent          closeButtonClassName="top-3 right-3"          className="h-auto max-w-md in-data-[form-size=lg]:max-w-4xl in-data-[form-size=md]:max-w-2xl"        >          <div            className={`flex max-h-(--screen-max-height) flex-col ${formSpacing}`}          >            <div className="shrink-0 p-3 pr-12 pb-0 has-[p]:pb-2">              <ExpandableScreenHeader>                <div className="flex min-h-8 items-center">                  <ExpandableScreenTitle>{title}</ExpandableScreenTitle>                </div>                {description && (                  <p className="text-sm text-muted-foreground">{description}</p>                )}              </ExpandableScreenHeader>            </div>            <div className="min-h-0 scroll-fade-y overflow-y-auto overscroll-contain px-3 pt-1 pb-3">              <FormCloseContext.Provider value={close}>                {children}              </FormCloseContext.Provider>            </div>          </div>        </ExpandableScreenContent>      </div>    </ExpandableScreen>  )}
API reference

FormFrame

display"page" | "dialog" | "screen" = FormDefaultsProvider, then formConfig.displayWhere the form shows.
width"sm" | "md" | "lg" = formConfig.widths[display]Width of the page, dialog or screen.
cardboolean = formConfig.layout === "card"Page in a card or a plain column.
titleReact.ReactNodeHeading. Also labels the dialog or screen.
descriptionReact.ReactNodeOne line under the title.
triggerReact.ReactElement = <Button>{title}</Button>Opens the dialog or screen. Ignored on a page.
Notes
  • The dialog and screen close after a successful submit.
  • The dialog and screen fit their form, up to 640px or 80% of the window. Longer forms scroll under a fixed title.
  • Card applies to pages only.
  • FormDefaultsProvider sets display, layout and widths for every form inside it. Nested providers override their parent.

Search docs

Find a component, input or example