# 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"`
- **Page:** <https://ui.booleanpress.com/components/inplace> · @booleanpress/ui 0.2.0

## 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.

```tsx
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 ✓.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| Key | Behaviour |
| --- | --- |
| Enter or Space | On the value, opens the field. |
| Enter | In the field, saves and returns focus to the value. In a `multiline` field it makes a new line. |
| Ctrl + Enter | In a `multiline` field, saves (⌘+Enter on a Mac). |
| Escape | Puts the value back, closes the field and returns focus to the value; a dialog around it stays open. While a save runs, it waits. |
| Tab | Moves to the ✓ and × buttons; leaving the field and its buttons saves. |

## API

### Inplace

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | What the value is ("Mailer name"): the field's name, and the display's hint, "Edit {label}". |
| `defaultOpen` | `boolean` | `false` | Whether it starts with the field open. |
| `defaultValue` | `string` |  | The starting value, when it controls itself. |
| `disabled` | `boolean` | `false` | The value cannot be edited. |
| `multiline` | `boolean` | `false` | Edit 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. |
| `open` | `boolean` |  | Whether the field is open, when you control it. Pair it with `onOpenChange`. |
| `placeholder` | `string` |  | Shown, 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. |
| `saveOnBlur` | `boolean` | `true` | Leaving the field saves it. `true` by default; `false` keeps it open until Enter, ✓, Escape or ×. |
| `showButtons` | `boolean` | `true` | Renders 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`. |
| `value` | `string` |  | The 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.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--control` | border |
| `--destructive` | text |
| `--destructive-ghost-active` | background |
| `--destructive-ghost-hover` | background |
| `--destructive-strong` | text |
| `--field` | background |
| `--field-filled` | background |
| `--foreground` | text |
| `--muted-foreground` | text |
| `--ring` | outline |
| `--success` | text |
| `--success-ghost-active` | background |
| `--success-ghost-hover` | background |
