Skip to content
design
Components

Inline edit

Renames a title or a single value where it is shown, around whatever field edits it.

On this page

Althea

A name changed in place: the pencil opens the caller's field, Enter saves, Escape cancels, and the pencil returns.
Code
import { InlineEdit, type InlineEditFieldProps } from "@bosunkit/components/inline-edit";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
import { useState, type Dispatch, type SetStateAction } from "react";

export const description: string =
  "A vessel's name renamed in place: the read view is the heading, the edit view the same width as a field, so the switch does not jump.";

export default function Rename(): ReactElement {
  const [name, setName]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Althea");
  return (
    <InlineEdit
      editLabel="Rename the vessel"
      value={name}
      readView={<h2>{name}</h2>}
      editView={(field: InlineEditFieldProps): ReactElement => (
        <Input
          {...field}
          aria-label="Vessel name"
          onChange={(event): void => field.onChange(event.target.value)}
        />
      )}
      validate={(value: string): string | undefined => (value.trim() === "" ? "A vessel has a name." : undefined)}
      saveAction={async (value: string): Promise<{ status: "saved" }> => {
        setName(value);
        return { status: "saved" };
      }}
    />
  );
}

InlineEdit renames a title or a single value where it is shown — a vessel, a passage, a checklist — without opening a form. It is a wrapper around the caller's field, whatever edits the value: a text field, a text area, a select. readView and editView are both the caller's, so the two align — the field is the same width and type as the text it edits, and the switch between them does not jump. Editing starts only from the pencil, drawn small to the right of the title and always visible, with a 48 px target around it and editLabel as its name and its tooltip: a stray tap on a moving vessel never opens an input. editLabel is a whole translated phrase, never "Edit" joined to the value's name, because word order and case differ by language.

Keys and focus. Enter saves and Escape cancels — without also closing an enclosing dialog. Shift+Enter is the field's, so a text area takes a line break. Moving focus away neither saves nor discards: the draft stays until save or cancel, and after either the focus returns to the pencil. The field receives the draft, its change and key callbacks, its disabled and invalid state, and a ref the edit uses to focus it with the cursor at the end.

A text area

Anchor watch at 40 m.

The same wrapper around a text area: the field can be whatever edits the value, and the read view is the text it replaces.
Code
import { InlineEdit, type InlineEditFieldProps } from "@bosunkit/components/inline-edit";
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";
import { useState, type Dispatch, type SetStateAction } from "react";

export const description: string =
  "The same wrapper around a text area: the field can be whatever edits the value, and the read view is the text it replaces.";

export default function Note(): ReactElement {
  const [note, setNote]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Anchor watch at 40 m.");
  return (
    <InlineEdit
      editLabel="Edit the note"
      value={note}
      readView={<p>{note}</p>}
      editView={(field: InlineEditFieldProps): ReactElement => (
        <TextArea
          {...field}
          aria-label="Note"
          rows={2}
          onChange={(event): void => field.onChange(event.target.value)}
        />
      )}
      validate={(): undefined => undefined}
      saveAction={async (value: string): Promise<{ status: "saved" }> => {
        setNote(value);
        return { status: "saved" };
      }}
    />
  );
}

The appearance follows the field: the same wrapper around a text area edits a note at the text's own width.

Nothing shows as saved before it is. validate runs first and its words appear beside the field, linked through aria-describedby with the field marked invalid. saveAction is an Action: while it is pending the field and both buttons are disabled and the save button shows the spinner. A rejection keeps the draft open with its message; only saved closes the edit on the new value.

Someone else's change

Night crossing

Someone else saved while the edit was open: the draft stays, with who saved what and how long ago, and keep mine or use theirs.
Code
import { InlineEdit, type InlineEditFieldProps, type InlineEditResult } from "@bosunkit/components/inline-edit";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "Someone else saved while the edit was open: the draft stays, with who saved what and how long ago, and keep mine or use theirs.";

export default function Conflict(): ReactElement {
  return (
    <InlineEdit
      editLabel="Rename the passage"
      value="Night crossing"
      readView={<h2>Night crossing</h2>}
      editView={(field: InlineEditFieldProps): ReactElement => (
        <Input
          {...field}
          aria-label="Passage name"
          onChange={(event): void => field.onChange(event.target.value)}
        />
      )}
      validate={(): undefined => undefined}
      saveAction={async (): Promise<InlineEditResult> => ({
        status: "conflict",
        other: { value: "Night crossing II", actor: "Marta", ageSeconds: 40 },
      })}
    />
  );
}

A conflict result, or an incoming change arriving while the edit is open, keeps the draft and shows the other value, who saved it and how long ago, with keep mine and use theirs. The read view updates from the caller's value, never from the draft.

Props

PropTypeDefaultDescription
editLabelrequiredstring–What the pencil does, translated by the caller as a whole phrase ("Rename the vessel"), never "Edit" joined to the value's name; word order and case differ by language. It is the pencil's name and its tooltip.
valuerequiredstring–The value shown while the edit is closed.
readViewrequiredReactNode–The read view: the caller's heading or text that holds the value.
editViewrequired(field: InlineEditFieldProps) => ReactNode–Renders the field the edit uses, from the props the edit hands it — a text field, a text area, a select. The caller passes both views so the two align: the field is the same width and type as the text it replaces, and the switch between them does not jump.
validaterequired(value: string) => string | undefined–Checks a draft and returns display text for an invalid one, or nothing when it is good.
saveActionrequired(value: string) => Promise<InlineEditResult>–Saves the draft and resolves to what happened; an Action, so the component shows its pending state.
incomingInlineEditIncoming | undefined–Someone else's change that arrived while the edit is open; shown as a conflict, not silently overwritten.
classNamestring | undefined–Classes added to the component's own.
testIdstring | undefined–Becomes data-testid on the component's root; parts are ${testId}--pencil and so on.