Inputs
Inline edit
Text that becomes an input when clicked, for renaming things in place, with pending and error states.
- Built on
<button><input>aria-livearia-describedby- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.7 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { InlineEdit } from "@/components/equip/inline-edit";
export default function Demo() {
return (
<div style={{ display: "grid", gap: 4, width: 216 }}>
<span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>Project</span>
<InlineEdit
label="Project name"
defaultValue="Larder billing"
style={{ fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}
/>
</div>
);
}Your versionchange its options in Look, This part
Examples
Pending and refused
onSave returns a promise: the input goes read-only with a spinner until it settles. Try a handle that is already taken, such as ana.
- Handle
- Bio
Code
"use client";
import { InlineEdit } from "@/components/equip/inline-edit";
const taken = ["ana", "admin", "support"];
function check(handle: string): Promise<string | void> {
return new Promise((resolve) => {
setTimeout(() => {
const clean = handle.trim().toLowerCase();
if (!/^[a-z0-9-]{3,20}$/.test(clean)) resolve("Use 3 to 20 letters, numbers or dashes.");
else if (taken.includes(clean)) resolve(`${clean} is taken.`);
else resolve();
}, 900);
});
}
export default function Example() {
return (
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", alignItems: "start", margin: 0, fontSize: "var(--eq-text-m)" }}>
<dt style={{ color: "var(--eq-ink-2)", paddingTop: 3 }}>Handle</dt>
<dd style={{ margin: 0 }}>
<InlineEdit label="Handle" defaultValue="ana-okafor" maxLength={20} onSave={check} />
</dd>
<dt style={{ color: "var(--eq-ink-2)", paddingTop: 3 }}>Bio</dt>
<dd style={{ margin: 0 }}>
<InlineEdit label="Bio" placeholder="Add a short bio" maxLength={80} />
</dd>
</dl>
);
}Renaming rows
Controlled values with blurAction="cancel", so clicking away in a dense list never saves by accident; only Enter does.
- sk_…k1f9
- sk_…k2f9
- sk_…k3f9
Code
"use client";
import { useState } from "react";
import { InlineEdit } from "@/components/equip/inline-edit";
export default function Example() {
const [keys, setKeys] = useState([
{ id: "k1", name: "Production deploys" },
{ id: "k2", name: "Staging" },
{ id: "k3", name: "Local testing" },
]);
return (
<ul style={{ display: "grid", gap: 4, width: "100%", maxWidth: 300, margin: 0, padding: 0, listStyle: "none", fontSize: "var(--eq-text-m)" }}>
{keys.map((key) => (
<li
key={key.id}
style={{ display: "flex", justifyContent: "space-between", gap: 12, padding: "6px 10px", border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)" }}
>
<InlineEdit
label="Key name"
value={key.name}
blurAction="cancel"
onSave={(next) => {
if (!next.trim()) return "A key needs a name.";
setKeys((all) => all.map((k) => (k.id === key.id ? { ...k, name: next.trim() } : k)));
}}
/>
<span style={{ color: "var(--eq-ink-3)", fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)", fontSize: "var(--eq-text-xs)", paddingTop: 4 }}>
sk_…{key.id}f9
</span>
</li>
))}
</ul>
);
}In a form
With name set, a hidden input carries the saved value. Enter inside the editor saves the text and never submits the form.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { InlineEdit } from "@/components/equip/inline-edit";
export default function Example() {
const [sent, setSent] = useState("");
return (
<form
style={{ display: "grid", gap: 12, justifyItems: "start", fontSize: "var(--eq-text-m)" }}
onSubmit={(event) => {
event.preventDefault();
setSent(String(new FormData(event.currentTarget).get("invoice_title")));
}}
>
<InlineEdit label="Invoice title" name="invoice_title" defaultValue="November retainer" style={{ fontWeight: "var(--eq-weight-strong)" }} />
<Button type="submit" variant="outline" size="s">
Send invoice
</Button>
{sent && <p style={{ margin: 0, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>invoice_title = {sent}</p>}
</form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | What the value is, such as Project name. Names the button and the input; not shown. |
value | string | none | Controlled value. |
defaultValue | string | "" | Uncontrolled starting value. |
onSave | (value: string) => void | string | Promise<void | string> | none | Called with the new text when it changed. Return or resolve a string to refuse the value and show it as the error; a rejected promise shows its message. While a promise is pending the input is read-only. |
onCancel | () => void | none | Called when editing ends without saving. |
placeholder | string | "Empty" | Shown in place of an empty value and as the input's placeholder. |
blurAction | "save" | "cancel" | "save" | What leaving the input does. Use cancel in dense lists where a stray click should not save. |
name | string | none | Adds a hidden input carrying the saved value, so it submits with a form. |
maxLength | number | none | Passed to the input. |
disabled | boolean | none | Shows the value without letting it be edited. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper, including style, which sets the font the text and input inherit. |
Anatomy
| root | .eq-inline-edit | data-editing while the input shows, data-pending while a save is in flight. |
|---|---|---|
| display | .eq-inline-edit-display | The button that shows the value and opens the editor. |
| text | .eq-inline-edit-text | data-empty when the placeholder is shown instead. |
| icon | .eq-inline-edit-icon | Pencil shown on hover and focus, always on touch screens. |
| field | .eq-inline-edit-field | Holds the input and the spinner. |
| input | .eq-inline-edit-input | |
| spinner | .eq-inline-edit-spinner | Shown while onSave's promise is pending. |
| error | .eq-inline-edit-error | Polite live region, linked to the input while it has a message. |
| status | .eq-inline-edit-status | Visually hidden status that announces a save. |
Keyboard
| EnterSpace | On the text, opens the editor with the text selected. |
|---|---|
| Enter | In the editor, saves. It never submits a surrounding form, and a held key does not save straight away. |
| Escape | Cancels and puts the old text back, without closing a surrounding dialog. |
| Tab | Leaves the editor, saving or cancelling as blurAction says. |
Notes
- The resting text is a real button named Edit, label, value, so it is reachable by Tab and says what it does.
- After a save or a cancel, focus returns to the text, unless the person has already moved focus somewhere else.
- A refused value keeps the editor open with aria-invalid and the message as its description; the message is also announced.
- Saving text that did not change closes the editor without calling onSave.
- Font, size and weight come from where it is placed, so the same part works in a heading, a table cell or a list row.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/inline-edit.tsx219 lines
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, FocusEvent, KeyboardEvent } from "react";
import "./inline-edit.css";
/** What onSave may hand back: nothing to accept the value, or a message to keep editing and show it. */
type SaveResult = void | string | null | undefined;
export type InlineEditProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** What the value is, such as "Project name". Names the button and the input; not shown. */
label: string;
/** Controlled value. */
value?: string;
/** Uncontrolled starting value. */
defaultValue?: string;
/**
* Called with the new text on Enter. Return or resolve a string to refuse it with that message;
* a rejected promise shows its message. While a promise is pending the input is read-only.
*/
onSave?: (value: string) => SaveResult | Promise<SaveResult>;
/** Called when editing ends without saving. */
onCancel?: () => void;
/** Shown in place of an empty value, and as the input's placeholder. */
placeholder?: string;
/** What leaving the input does. */
blurAction?: "save" | "cancel";
/** Adds a hidden input with the saved value, so it submits with a form. */
name?: string;
maxLength?: number;
disabled?: boolean;
};
function messageOf(reason: unknown) {
return reason instanceof Error && reason.message ? reason.message : "Could not save. Try again.";
}
export function InlineEdit({
label,
value,
defaultValue = "",
onSave,
onCancel,
placeholder = "Empty",
blurAction = "save",
name,
maxLength,
disabled,
className,
...rest
}: InlineEditProps) {
const id = useId();
const errorId = `${id}-error`;
const [inner, setInner] = useState(defaultValue);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const shown = value ?? inner;
const display = useRef<HTMLButtonElement>(null);
const input = useRef<HTMLInputElement>(null);
/** Where focus goes after the next render: into the input, back to the button, or nowhere. */
const focusNext = useRef<"input" | "display" | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
const target = focusNext.current;
if (!target) return;
focusNext.current = null;
if (target === "input") {
input.current?.focus();
input.current?.select();
} else if (!document.activeElement || document.activeElement === document.body) {
// Only when focus was lost with the input; if the person has moved on, leave it there.
display.current?.focus();
}
});
function start() {
if (disabled) return;
setDraft(shown);
setError("");
setStatus("");
setEditing(true);
focusNext.current = "input";
}
function finish(next: string | null, refocus: boolean) {
if (next !== null) {
if (value === undefined) setInner(next);
setStatus(`${label} saved`);
}
setEditing(false);
setError("");
setPending(false);
if (refocus) focusNext.current = "display";
}
function cancel(refocus: boolean) {
finish(null, refocus);
onCancel?.();
}
async function save(refocus: boolean) {
if (pending) return;
const next = draft;
if (next === shown) {
finish(null, refocus);
return;
}
let result: SaveResult;
try {
const returned = onSave?.(next);
if (returned instanceof Promise) {
setPending(true);
setError("");
result = await returned;
} else {
result = returned;
}
} catch (reason) {
result = messageOf(reason);
}
if (!mounted.current) return;
if (typeof result === "string" && result) {
setPending(false);
setError(result);
// Back in the input so the message and the text are both in front of the person.
if (refocus) focusNext.current = "input";
return;
}
finish(next, refocus);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter") {
// Never submits a surrounding form, and a held Enter from opening the editor does not save at once.
event.preventDefault();
if (event.repeat || event.nativeEvent.isComposing) return;
void save(true);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (!pending) cancel(true);
}
}
function handleBlur(event: FocusEvent<HTMLInputElement>) {
if (pending || event.currentTarget.contains(event.relatedTarget)) return;
if (blurAction === "save") void save(false);
else cancel(false);
}
return (
<div
{...rest}
className={["eq-inline-edit", className].filter(Boolean).join(" ")}
data-editing={editing || undefined}
data-pending={pending || undefined}
>
{editing ? (
<span className="eq-inline-edit-field">
<input
ref={input}
className="eq-inline-edit-input"
type="text"
aria-label={label}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
aria-busy={pending || undefined}
readOnly={pending}
placeholder={placeholder}
maxLength={maxLength}
value={draft}
size={Math.max(4, draft.length + 1)}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError("");
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
{pending && <span className="eq-inline-edit-spinner" aria-hidden="true" />}
</span>
) : (
<button
ref={display}
type="button"
className="eq-inline-edit-display"
aria-label={`Edit ${label}, ${shown || "empty"}`}
disabled={disabled}
onClick={start}
>
<span className="eq-inline-edit-text" data-empty={shown ? undefined : true}>
{shown || placeholder}
</span>
<svg className="eq-inline-edit-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10.5 3 13 5.5 6 12.5l-3 .5.5-3 7-7Z" />
</svg>
</button>
)}
<p className="eq-inline-edit-error" id={errorId} aria-live="polite">
{editing ? error : ""}
</p>
<span className="eq-inline-edit-status" role="status">
{status}
</span>
{name && <input type="hidden" name={name} value={shown} />}
</div>
);
}components/equip/inline-edit.css176 lines
@layer equip {
/* Reads as the text it edits: font and size come from wherever it is placed. */
.eq-inline-edit {
--_pad-x: var(--eq-space-1);
--_pad-y: 2px;
display: inline-grid;
gap: var(--eq-space-1);
min-width: 0;
max-width: calc(100% + 2 * var(--_pad-x) + 2px);
/* Pulled out by the box's padding, so the text lines up with the text around it. */
margin-inline: calc(-1 * var(--_pad-x) - 1px);
vertical-align: top;
color: var(--eq-ink);
}
.eq-inline-edit-display,
.eq-inline-edit-input {
box-sizing: border-box;
min-height: 24px;
margin: 0;
padding: var(--_pad-y) var(--_pad-x);
border: 1px solid transparent;
border-radius: var(--eq-radius-s);
color: inherit;
font: inherit;
line-height: 1.35;
}
.eq-inline-edit-display {
display: inline-flex;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
max-width: 100%;
background: transparent;
text-align: start;
cursor: text;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-inline-edit-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-inline-edit-text[data-empty] {
color: var(--eq-ink-3);
}
/* The pencil says it can be edited; it shows on hover and focus so resting text stays quiet. */
.eq-inline-edit-icon {
flex: none;
width: 14px;
height: 14px;
color: var(--eq-ink-3);
opacity: 0;
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-inline-edit-display:focus-visible .eq-inline-edit-icon {
opacity: 1;
}
@media (hover: hover) {
.eq-inline-edit-display:hover:not(:disabled) {
background: var(--eq-tint);
}
.eq-inline-edit-display:hover:not(:disabled) .eq-inline-edit-icon {
opacity: 1;
}
}
/* Touch has no hover, so the pencil stays visible there. */
@media (hover: none) {
.eq-inline-edit-icon {
opacity: 1;
}
}
.eq-inline-edit-display:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-inline-edit-display:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-inline-edit-display:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-inline-edit-field {
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
}
.eq-inline-edit-input {
min-width: 6ch;
max-width: 100%;
border-color: var(--eq-line-strong);
background: var(--eq-raised);
appearance: none;
}
.eq-inline-edit-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-inline-edit-input:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-inline-edit-input[aria-invalid="true"] {
border-color: var(--eq-danger);
}
.eq-inline-edit[data-pending] .eq-inline-edit-input {
padding-inline-end: calc(var(--_pad-x) + 18px);
color: var(--eq-ink-2);
cursor: progress;
}
.eq-inline-edit-spinner {
position: absolute;
inset-inline-end: var(--eq-space-1);
width: 12px;
height: 12px;
border: 1.5px solid var(--eq-tint-strong);
border-top-color: var(--eq-ink-2);
border-radius: var(--eq-radius-full);
animation: eq-inline-edit-spin 0.8s linear infinite;
}
@keyframes eq-inline-edit-spin {
to {
rotate: 1turn;
}
}
.eq-inline-edit-error {
margin: 0;
padding-inline: calc(var(--_pad-x) + 1px);
color: var(--eq-danger);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
/* Empty, the live region stays in the page but takes no room and no gap. */
.eq-inline-edit-error:empty,
.eq-inline-edit-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* A spinner still reports that something is happening, so it slows down rather than stops. */
@media (prefers-reduced-motion: reduce) {
.eq-inline-edit-spinner {
animation-duration: 2.4s;
}
}
}