Inline edit
Renames a title or a single value where it is shown, around whatever field edits it.
On this page
Althea
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.
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
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
| Prop | Type | Default | Description |
|---|---|---|---|
| editLabelrequired | string | – | 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. |
| valuerequired | string | – | The value shown while the edit is closed. |
| readViewrequired | ReactNode | – | 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. |
| incoming | InlineEditIncoming | undefined | – | Someone else's change that arrived while the edit is open; shown as a conflict, not silently overwritten. |
| className | string | undefined | – | Classes added to the component's own. |
| testId | string | undefined | – | Becomes data-testid on the component's root; parts are ${testId}--pencil and so on. |