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-frameNeeds 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
PropDescription
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.