Editable text
Click-to-edit text: Enter or blur commits and Escape restores.
Supplier since March 2024, paid by ACH on Net 30.
- Remit-to email
- Vendor ID
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() { const [name, setName] = React.useState("Northwind Freight LLC"); const [email, setEmail] = React.useState("ap@northwindfreight.com"); const [vendorId, setVendorId] = React.useState("V-004417"); const rows = [ { label: "Remit-to email", value: email, set: setEmail, mono: false }, { label: "Vendor ID", value: vendorId, set: setVendorId, mono: true }, ]; return ( <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-5 text-left shadow-border"> <div className="flex flex-col gap-1"> <div className="text-xl font-semibold tracking-[-0.015em] text-foreground"> <EditableText label="Supplier name" value={name} onCommit={(next) => { if (!next) { toast.add({ type: "error", title: "A supplier needs a name", }); return; } setName(next); toast.add({ type: "success", title: `Renamed to ${next}`, }); }} className="-ml-1.5" /> </div> <p className="text-13 text-muted-foreground"> Supplier since March 2024, paid by ACH on Net 30. </p> </div> <dl className="flex flex-col divide-y divide-border rounded-[10px] bg-muted/70 px-3"> {rows.map((row) => ( <div key={row.label} className="grid grid-cols-[8rem_minmax(0,1fr)] items-center gap-3 py-1.5" > <dt className="text-13 text-muted-foreground"> {row.label} </dt> <dd className={cn( "min-w-0 text-13", row.mono && "font-mono text-xs", )} > <EditableText label={row.label} value={row.value} onCommit={(next) => { row.set(next); toast.add({ title: `Updated ${row.label.toLowerCase()}`, description: next || "Cleared", }); }} className="h-8 px-2" inputClassName="h-8 px-2" /> </dd> </div> ))} </dl> </div> );}Usage#
Editable text shows a value as plain text and turns it into an input when clicked, for renaming in place: a workflow name in the header, a ticket title, a supplier's legal name in its attribute list. Enter or clicking away commits, Escape restores, and there is no Save button. The mistake people make is styling it only through className. That styles the resting button; the input that appears on click inherits its type from the parent, so put the font size and weight on the wrapper, or pass them to inputClassName too, or the text jumps when editing starts.
When to use
- To rename the thing a page is about, in its header: a workflow, a ticket, a supplier.
- For short text attributes in a record's attribute list, one value per row.
- For a description that people edit rarely, with
multiline. - Where each change saves on its own and confirms with a toast or a quiet saved state.
When not to use
- In a form with a Save button, where all fields commit together. Use Field
- For a value with a fixed set of options, such as stage or tier. Use Select
- For long-form text such as prompts or instructions. Use Prompt editor
- For a value people copy more often than they change, such as an ID. Use Copy row
Every change is acknowledged
The Machine Mono Rule
className and inputClassName.Anatomy#
- Display button. A full-width
<button>with 8px corners and 6px side padding, showing the value truncated to one line (three withmultiline). - Placeholder. Shown in Faint Slate when the value is empty: Empty by default.
- Editor. An input, or a three-row textarea with
multiline, on the White Plane with a 2px indigo ring at 50%. It opens with the text selected.
Examples#
Attribute list
One Editable text per row at 32px and 13px, with the attribute name as its label. Pass the same height and padding to inputClassName so the row doesn't move when it opens.
- Legal name
- Billing email
- Payment terms
- W-9 on file
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Attributes() { const [values, setValues] = React.useState({ legal: "Orchard Street Produce Co.", email: "billing@orchardstreet.com", terms: "Net 45", w9: "", }); const rows = [ { key: "legal", label: "Legal name" }, { key: "email", label: "Billing email" }, { key: "terms", label: "Payment terms" }, { key: "w9", label: "W-9 on file" }, ] as const; return ( <dl className="flex w-full max-w-md flex-col rounded-xl bg-card py-1 shadow-border"> {rows.map((row) => ( <div key={row.key} className="grid grid-cols-[8rem_minmax(0,1fr)] items-center gap-3 px-4 py-1" > <dt className="text-13 text-muted-foreground"> {row.label} </dt> <dd className="min-w-0 text-13"> <EditableText label={row.label} value={values[row.key]} onCommit={(next) => { setValues((current) => ({ ...current, [row.key]: next, })); toast.add({ title: `Updated ${row.label.toLowerCase()}`, }); }} className="h-8 px-2" inputClassName="h-8 px-2" /> </dd> </div> ))} </dl> );}Multiline
A three-row textarea that commits on ⌘Enter. At rest the text clamps to three lines and keeps its line breaks.
Enter adds a line. ⌘Enter saves, Escape cancels.
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Multiline() { const [notes, setNotes] = React.useState( "Remits by ACH on Fridays. Send remittance advice to billing@orchardstreet.com and copy Wen Zhou on anything over $10,000.", ); return ( <div className="w-full max-w-md text-sm leading-relaxed text-muted-foreground"> <EditableText label="Payment notes" value={notes} placeholder="Add payment notes" multiline onCommit={(next) => { setNotes(next); toast.add({ title: "Saved payment notes" }); }} className="-mx-1.5 text-pretty" /> <p className="mt-2 text-xs text-subtle-foreground"> Enter adds a line. ⌘Enter saves, Escape cancels. </p> </div> );}In a page header
The last crumb of the app header renames the workflow in place. The wrapper carries the 13px medium type, so the editor matches it.
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import { ChevronRightIcon } from "lucide-react";import * as React from "react";export function InHeader() { const [name, setName] = React.useState("Invoice approval over $25,000"); return ( <div className="flex h-12 w-full max-w-2xl items-center gap-1.5 rounded-xl bg-background px-3 text-13 shadow-border"> <span className="text-muted-foreground">Workflows</span> <ChevronRightIcon aria-hidden="true" className="size-3.5 text-subtle-foreground" /> <div className="min-w-0 font-medium text-foreground"> <EditableText label="Workflow name" value={name} onCommit={(next) => { if (!next) return; setName(next); toast.add({ type: "success", title: `Renamed to ${next}`, }); }} className="-ml-1.5 w-auto max-w-md" /> </div> </div> );}States#
import { EditableText } from "@oration/canon/components/editable-text";import { cn } from "@oration/canon/lib/utils";export function StatesRow() { const states = [ { name: "Rest", className: "" }, { name: "Hover", className: "bg-muted" }, { name: "Focus", className: "ring-3 ring-ring/40" }, ]; return ( <div inert className="grid w-full grid-cols-1 gap-6 text-13 sm:grid-cols-2 lg:grid-cols-5" > {states.map((state) => ( <div key={state.name} className="pointer-events-none flex min-w-0 flex-col gap-2" > <span className="text-xs text-muted-foreground"> {state.name} </span> <EditableText label={`Legal name, ${state.name}`} value="Halcyon Logistics" onCommit={() => undefined} className={cn("h-8 px-2", state.className)} /> </div> ))} <div className="pointer-events-none flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Editing</span> <input aria-label="Legal name, Editing" tabIndex={-1} readOnly defaultValue="Halcyon Logistics" className="h-8 w-full min-w-0 rounded-md bg-background px-2 py-1 text-inherit ring-2 ring-ring/50 outline-none" /> </div> <div className="pointer-events-none flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Empty</span> <EditableText label="Trade name" value="" onCommit={() => undefined} className="h-8 px-2" /> </div> </div> );}| State | Treatment |
|---|---|
| Rest | The value as plain text; nothing marks it as editable until hover. |
| Hover | A Well Gray fill over 100ms and a text cursor. |
| Focus visible | A 3px indigo ring at 40% on the display button. |
| Editing | The input replaces the button in place, with a 2px indigo ring at 50% and the text selected. |
| Empty | The placeholder in Faint Slate. |
Behavior#
- Click, Enter or Space on the display button opens the editor with the whole value selected.
- Enter commits a single-line value. With
multiline, Enter adds a new line and ⌘Enter commits. - Escape restores the previous value and closes the editor. Blur commits.
- The committed value is trimmed.
onCommitonly runs when it differs fromvalue, and it does run with an empty string, so guard empties in your handler. - It holds no saved state of its own: update
valuefromonCommit, and a newvaluefrom outside replaces the draft while not editing. classNamestyles the display button;inputClassNamestyles the editor. The editor's font comes from the parent element.
Do and don't#
text-lg only on className. Click it: the editor drops to 14px and the header jumps.Content#
- The placeholder invites the first value: Add a description, Add a legal name. Use Empty only in dense attribute lists.
- Confirm renames with the new name: Renamed to Halcyon Logistics.
- If an empty value isn't allowed, restore the old one and say why in a toast: A supplier needs a name.
Accessibility#
labelis required. The display button is announced as {label}: {value}. Edit, and the editor useslabelas its name.- The display is a real
<button>, so it is in the tab order and opens with Enter or Space. - The editor takes focus when it opens and selects its text.
- After Enter or Escape, focus is not returned to the display button; see the gaps below.
- Keep the hit area at least 24px tall;
h-7orh-8in attribute lists.
| Keys | Action |
|---|---|
| Enter | On the display button, opens the editor. In a single-line editor, commits. |
| Space | Opens the editor from the display button. |
| ⌘Enter | Commits a multiline value. |
| Esc | Restores the previous value and closes the editor. |
| Tab | Leaves the editor, which commits. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Hover fill |
--ring | Focus ring at 40% and the editing ring at 50% |
--background | Editor fill |
--subtle-foreground | Placeholder text |
--radius-md | 8px corners |
API reference#
EditableText
Click-to-edit text.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The saved value. |
onCommitRequired | (value: string) => void | No default | Called with the trimmed value when it changed. |
labelRequired | string | No default | The attribute's name, used for both the button and the editor. |
placeholder | string | "Empty" | Shown when the value is empty. |
multiline | boolean | false | A three-row textarea; ⌘Enter commits and the display clamps to three lines. |
className | string | No default | Classes for the display button. |
inputClassName | string | No default | Classes for the editor. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Focus isn't returned to the display button after Enter or Escape. The editor unmounts and focus falls to the page, so keyboard users lose their place.
Multiline commits only on ⌘Enter (metaKey). On Windows and Linux, Ctrl+Enter inserts a new line instead.
Empty values commit. There is no validation, error or pending state for a save that fails.
className doesn't reach the editor. Call sites that set heading type on it, such as the ticket title, change size when editing starts.
The editing ring is 2px while the suite's field focus ring is 3px, and the hover fill runs 100ms against the 150ms standard.