Text that edits in place: hover or focus reveals a pencil, a click (or Enter) swaps the text for an input, Enter or blur commits, Escape reverts. Both states render inside the same box metrics, so nothing around it shifts when editing starts (inline-edit.tsx:8-13). The caller supplies whatever typography the spot needs, a heading, a table cell, and wraps InlineEdit inside it.
The canon behind it. docs/design-language/00-overview.md model 4 (00-overview.md:71, feedback is local and layered, belonging to the thing that is loading) and docs/design-language/02-buttons-and-actions.md §A5 (02-buttons-and-actions.md:31, pending state lives on the control performing the action, never a status badge).
When to reach for it#
Renaming something in place, without a separate edit modal or route: a job or run's title, a name inside a table cell. Speedway-only as of this pass; no walmart usage found.
Contract#
value: the text currently shown. The caller owns it and re-renders with the committed value;InlineEditholds only a local edit-mode draft.onCommit(next): value-first, receives the trimmed new text on Enter or blur. May return a promise: the control shows a pending state (a spinner replaces the save icon) until it settles, and stays in edit mode with the draft intact if it rejects. The component itself renders no error text; the caller surfaces the failure. Never called for an empty-after-trim or unchanged draft, the control just drops back to read mode instead.disabled: renders the plain text with no pencil affordance and no edit swap.ariaLabel: accessible name for the edit control. Defaults to`Edit "<value>"`.- Slot classnames per
InlineEditClassNames:""the read/edit container,inputthe edit-mode input.
Mechanics#
Escape sets a ref flag (cancelledRef) before dropping out of edit mode, because Escape also triggers the input's blur event, and without the flag that blur would immediately re-commit the value Escape just cancelled (inline-edit.tsx:27-29,64-68,86-91). The save button's own onClick is mostly redundant with blur-commit for a mouse click (clicking it blurs the input first), but it exists as the keyboard/assistive-tech path; commit()'s own pending guard keeps the pair from double-firing (inline-edit.tsx:99-101). The input's size attribute is Math.max(draft.length, 4), so the box grows with the text instead of clipping or wrapping mid-edit (inline-edit.tsx:93).
Real usage#
All three shipped call sites are speedway rename flows, each committing through the same rename.submit({ intent: "rename-job" | "rename-run", ... }) fetcher pattern:
speedway/app/routes/workspaces/jobs/job.tsx:256-272: the job page's<h1>title.PageTitleonly auto-styles a string title, so a JSX title like this one has to dress itself.speedway/app/routes/workspaces/jobs/run.tsx:345-359: the same pattern for a run's title, falling back to a generated name (`${MODULE_TITLES[job.module]} run`) whenjob.nameis empty.speedway/app/components/JobsTable.tsx:617-639: inline rename directly inside a table cell, wrapped in a<span onClick={(e) => e.stopPropagation()}>so entering edit mode never also opens the row, withclassName="w-full min-w-[30ch]"so the name can never crunch below 30 characters in that column (owner ruling,JobsTable.tsx:629-630).
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
Wrapped in a JSX <h1> inside PageTitle's title slot | An inline-renamable page heading | job.tsx:256-272, run.tsx:340-359 | PageTitle cannot auto-style JSX titles, so the heading classes move onto the wrapping <h1> |
Wrapped in a stopPropagation span with a min-w-[Nch] floor, inside a table cell's render | An inline-renamable table cell that never triggers the row's own click | JobsTable.tsx:617-639 | The row is itself clickable (opens the job); the rename control must not also fire that |
Banned combinations#
None found. No shipped call site sets disabled, and no shipped call site exists in walmart; both are implemented and correct, not proven in production use outside speedway's rename flows.
Before you adopt this#
Five questions to answer before reaching for InlineEdit.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, InlineEdit is inline content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Not applicable, no local InlineEdit clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? Not applicable, no shipped call site exists outside speedway's rename flows yet.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them?
ariaLabeldefaults toEdit "<value>"; Escape and blur are handled, not the caller's job. - Which canon §A rules bind this surface, and which does the composition break? §A5 keeps the pending state on the control, never a status badge.
Travels with#
PageTitle, for the JSX-title wrapping pattern above.RenderIcon, internally, for the pencil/save glyphs.
Snippet#
// speedway/app/routes/workspaces/jobs/job.tsx:256-272 (trimmed)<PageTitle className="shrink-0" title={ // PageTitle styles STRING titles only; a JSX title must dress itself. <h1 className="truncate text-2xl font-bold"> <InlineEdit value={job.name} ariaLabel="Rename this job" onCommit={(next) => rename.submit( { intent: "rename-job", jobId: job.id, name: next }, { method: "post", action: `/workspaces/${wid}/jobs` }, ) } /> </h1> } subtitle={subtitle}/>