Skip to the content

ComponentsForm

Inplace

A value shown as text that turns into a field on click or Enter, and saves on Enter or when the field is left.

Import

import { Inplace } from "@booleanpress/ui/inplace"

Usage

Use it where people read a value far more often than they change it: a name in a details page, a subject in a list. The value is a button; pressing it opens the field with the value selected.

import { Inplace } from "@booleanpress/ui/inplace"

export function MailerName({ name, rename }: { name: string; rename: (name: string) => Promise<void> }) {
  return <Inplace label="Mailer name" value={name} onSave={rename} />
}

label names the field and gives the value its hint, the provider's edit string ("Edit {label}"). Leave value out and give defaultValue for an inplace that keeps its own value; give value and update it in onValueChange (or onSave) to control it. open, defaultOpen and onOpenChange control the field the same way.

Enter, the ✓ button or leaving the field saves; Escape or the × puts the value back. onSave runs with the new value (not when it is unchanged): return a promise and a spinner shows until it settles; reject it and the field stays open with the error's message, or the provider's saveFailed. While it saves, Escape and the × wait for it. A save that settles after the inplace has left the page is ignored. saveOnBlur={false} keeps the field open when focus leaves it; showButtons={false} removes the ✓ and ×.

multiline edits in a Textarea, where Enter makes a new line and Ctrl+Enter or ⌘+Enter saves; the value then keeps its line breaks when it is shown. renderDisplay draws the value your way (a badge, an image), and renderEditor puts your own control in place of the field: spread its fieldProps on the control so it takes focus and is named.

Examples

Basic

A mailer's name: click it, edit, and press Enter or the ✓.

import { Inplace } from "@booleanpress/ui/inplace"

export default function InplaceBasic() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-1">
      <span className="ps-2.5 text-xs font-medium text-muted-foreground uppercase">Mailer name</span>
      <Inplace label="Mailer name" defaultValue="Primary SMTP" placeholder="Name this mailer" className="w-full" />
    </div>
  )
}

Textarea

multiline: Enter makes a new line, Ctrl+Enter or ⌘+Enter saves.

import { Inplace } from "@booleanpress/ui/inplace"

export default function InplaceTextarea() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-1">
      <span className="ps-2.5 text-xs font-medium text-muted-foreground uppercase">Email footer</span>
      <Inplace
        label="Email footer"
        multiline
        defaultValue="You receive this email because you have an account at example.com."
        className="w-full"
      />
      <p className="ps-2.5 text-xs text-muted-foreground">Ctrl+Enter or ⌘+Enter saves; Enter starts a new line.</p>
    </div>
  )
}

Controlled

value and open held by the page; a button outside opens and closes the field.

import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { Inplace } from "@booleanpress/ui/inplace"

export default function InplaceControlled() {
  const [name, setName] = useState("Ana Ruiz")
  const [open, setOpen] = useState(false)

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <span className="ps-2.5 text-xs font-medium text-muted-foreground uppercase">Customer</span>
      <Inplace label="Customer name" value={name} onValueChange={setName} open={open} onOpenChange={setOpen} className="w-full" />
      <Button variant="outline" onClick={() => setOpen(!open)}>
        {open ? "Close the field" : "Edit name"}
      </Button>
    </div>
  )
}

Async save with error

onSave waits for a request; a refused subject keeps the field open with the error.

import { Inplace } from "@booleanpress/ui/inplace"

// Stands in for the request that saves the subject: a subject that mentions "free" is refused.
const saveSubject = (value: string) =>
  new Promise<void>((resolve, reject) =>
    setTimeout(
      () => (/free/i.test(value) ? reject(new Error("Subjects with “free” are refused by the spam filter.")) : resolve()),
      1200
    )
  )

export default function InplaceAsyncSave() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-1">
      <span className="ps-2.5 text-xs font-medium text-muted-foreground uppercase">Subject</span>
      <Inplace label="Subject" defaultValue="Your October invoice" onSave={saveSubject} className="w-full" />
      <p className="ps-2.5 text-xs text-muted-foreground">Try a subject with the word “free” to see the error.</p>
    </div>
  )
}

Disabled

disabled shows the value without a way to edit it.

import { Inplace } from "@booleanpress/ui/inplace"

export default function InplaceDisabled() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-1">
      <span className="ps-2.5 text-xs font-medium text-muted-foreground uppercase">Sending domain</span>
      <Inplace label="Sending domain" defaultValue="mail.example.com" disabled className="w-full" />
    </div>
  )
}

Custom display

renderDisplay shows a status badge and renderEditor a native select.

import { Badge } from "@booleanpress/ui/badge"
import { Inplace } from "@booleanpress/ui/inplace"
import { NativeSelect, NativeSelectOption } from "@booleanpress/ui/native-select"

const STATUSES = { active: "Active", paused: "Paused", archived: "Archived" } as const
type Status = keyof typeof STATUSES

const VARIANTS = { active: "success", paused: "warning", archived: "secondary" } as const

export default function InplaceCustomDisplay() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-1">
      <span className="ps-2.5 text-xs font-medium text-muted-foreground uppercase">Status</span>
      <Inplace
        label="Mailer status"
        defaultValue="active"
        showButtons={false}
        renderDisplay={(value) => <Badge variant={VARIANTS[value as Status]}>{STATUSES[value as Status]}</Badge>}
        renderEditor={({ value, onValueChange, fieldProps }) => (
          <NativeSelect {...fieldProps} value={value} onChange={(event) => onValueChange(event.target.value)}>
            {Object.entries(STATUSES).map(([key, text]) => (
              <NativeSelectOption key={key} value={key}>
                {text}
              </NativeSelectOption>
            ))}
          </NativeSelect>
        )}
      />
    </div>
  )
}

Accessibility

Semantics
Closed, a native button whose name is the value and whose description is the hint "Edit {label}". Open, a role="group" holding the field (named by label) and the ✓ and × buttons. While saving, the field is aria-busy and read-only; an error is aria-invalid, linked to its role="alert" message by aria-describedby.
Labels
label is required: it names the field and fills the hint. The ✓ and × are named by the provider's save and cancel strings; the spinner by saving.
Focus
Opening moves focus into the field and selects its text. Enter, Escape, ✓ and × return focus to the value. Leaving the field for another control saves without pulling focus back. Focus on the value is a 1px --ring outline 2px outside it.
Known limits
  • An empty value shows the placeholder; give one, or the button has no visible text.
  • Escape in the field cancels the edit only: inside a dialog, sheet or popover, the outer layer stays open.

Keyboard

Keyboard
KeyBehaviour
EnterorSpaceOn the value, opens the field.
EnterIn the field, saves and returns focus to the value. In a multiline field it makes a new line.
CtrlEnterIn a multiline field, saves (⌘+Enter on a Mac).
EscapePuts the value back, closes the field and returns focus to the value; a dialog around it stays open. While a save runs, it waits.
TabMoves to the ✓ and × buttons; leaving the field and its buttons saves.

API

Inplace

Renders a div and passes it every other prop.

Inplace props
PropTypeDefaultDescription
labelrequiredstringWhat the value is ("Mailer name"): the field's name, and the display's hint, "Edit {label}".
defaultOpenbooleanfalseWhether it starts with the field open.
defaultValuestringThe starting value, when it controls itself.
disabledbooleanfalseThe value cannot be edited.
multilinebooleanfalseEdit in a Textarea; Enter then makes a new line and Ctrl+Enter or ⌘+Enter saves.
onOpenChange((open: boolean) => void)Called with true or false when the field opens or closes.
onSave((value: string) => unknown)Called with the new value when it is saved. Return a promise to show a spinner until it settles; reject it to keep the field open with the error's message (or the provider's saveFailed). Not called when the value is unchanged.
onValueChange((value: string) => void)Called with the new value once it is saved.
openbooleanWhether the field is open, when you control it. Pair it with onOpenChange.
placeholderstringShown, muted, when the value is empty, and in the empty field.
renderDisplay((value: string) => ReactNode)Renders the display's content from the value: a badge, an image.
renderEditor((props: InplaceEditorProps) => ReactNode)Renders your own editor in place of the Input or Textarea.
saveOnBlurbooleantrueLeaving the field saves it. true by default; false keeps it open until Enter, ✓, Escape or ×.
showButtonsbooleantrueRenders the ✓ and × buttons after the field.
size"default" | "sm" | "lg"The size of the display and the field: 28, 35 or 42 px. Defaults to the provider's controlSize.
valuestringThe value, when you control it. Update it in onSave or onValueChange.
variant"default" | "filled"The field's look. Defaults to the provider's fieldVariant.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="inplace" (Inplace), and data-size.

Provider strings: saveFailed, edit, save, saving, cancel (BooleanUIProvider's strings).

Theming

Closed, the value has no edge and takes the --accent fill on hover, 6px radius. Open, it is the library's Input or Textarea, with the ✓ (--success) and × (--destructive) as addons on its --control edge.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--controlborder
--destructivetext
--destructive-ghost-activebackground
--destructive-ghost-hoverbackground
--destructive-strongtext
--fieldbackground
--field-filledbackground
--foregroundtext
--muted-foregroundtext
--ringoutline
--successtext
--success-ghost-activebackground
--success-ghost-hoverbackground