InlineEdit
InlineEdit edits one line of text in place, saving with Enter or Save. Use it for a single field on a detail view, such as a name. For several fields use a form with Field, and for longer text use Textarea.
Priya Shah
'use client';
import { InlineEditExample } from '@/components/nxds/blocks/editing/inline-edit/inline-edit-example';
export default function InlineEditDefault() {
return <InlineEditExample />;
}
Installation
npx nxds-cli@latest add inline-edit
Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.
Source
To install by hand, copy these files into your project. Paths are relative to packages/react/src/. Copy each file to the same path under components/nxds/, and the imports between files work without edits.
Install these components first: Button, Field, Input.
'use client';
import { useState } from 'react';
import { InlineEdit } from './inline-edit';
export function InlineEditExample() {
const [name, setName] = useState('Priya Shah');
return <InlineEdit label="Name" value={name} onCommit={setName} />;
}
'use client';
import * as React from 'react';
import { Button } from '../../../components/button/button';
import { FieldError } from '../../../components/field/field';
import { Input } from '../../../components/input/input';
import { IconCheck, IconPencil, IconX } from '../../../lib/icons';
import { cn } from '../../../lib/utils';
type InlineEditBlurBehavior = 'keep-open' | 'save' | 'cancel';
type InlineEditCommit = (value: string) => void | Promise<void>;
export interface InlineEditProps extends Omit<
React.ComponentProps<'div'>,
'onChange'
> {
value: string;
label: string;
/**
* Gets the trimmed draft. Resolving closes the editor. Rejecting keeps the
* draft open so the user can retry, so pass `error`.
*/
onCommit: InlineEditCommit;
/** Opens the editor. Omit it to let InlineEdit manage this. */
editing?: boolean;
onEditingChange?: (editing: boolean) => void;
/** Shows the value with no edit trigger. */
readOnly?: boolean;
/** Validation or save error, shown while the editor is open. */
error?: React.ReactNode;
/** `pencil` adds an edit button beside the value. `click` makes the value the button. */
activation?: 'pencil' | 'click';
/** What happens when focus leaves the editor and its buttons. */
blurBehavior?: InlineEditBlurBehavior;
required?: boolean;
/** Shown when a required value is saved empty. Defaults to `{label} is required.` */
requiredMessage?: string;
emptyText?: string;
placeholder?: string;
}
// Pulls the field's padding and border outside the text, so editing text sits
// where the display text did.
const alignWithText =
'nx:-ms-[calc(var(--nx-spacing-2_5)+var(--nx-borderwidth-default))]';
function focusInput(input: HTMLInputElement | null) {
input?.focus();
}
// The trigger mounts in the same commit that removes the editor, so it is
// only attached once that commit finishes.
function focusAfterCommit(target: React.RefObject<HTMLElement | null>) {
queueMicrotask(() => target.current?.focus());
}
function InlineEditValue({
value,
emptyText,
}: {
value: string;
emptyText: string;
}) {
const empty = value.trim().length === 0;
return (
<span
data-slot="inline-edit-value"
className={cn(
'nx:min-w-0 nx:wrap-anywhere',
empty && 'nx:text-muted-foreground'
)}
>
{empty ? emptyText : value}
</span>
);
}
interface InlineEditEditorProps {
value: string;
label: string;
error: React.ReactNode;
blurBehavior: InlineEditBlurBehavior;
required: boolean;
requiredMessage: string;
placeholder?: string;
onCommit: InlineEditCommit;
onClose: () => void;
trigger: React.RefObject<HTMLButtonElement | null>;
}
function InlineEditEditor({
value,
label,
error,
blurBehavior,
required,
requiredMessage,
placeholder,
onCommit,
onClose,
trigger,
}: InlineEditEditorProps) {
const [draft, setDraft] = React.useState(value);
const [missing, setMissing] = React.useState(false);
const [pending, setPending] = React.useState(false);
const group = React.useRef<HTMLDivElement>(null);
const errorId = React.useId();
const message = missing ? requiredMessage : error;
const invalid = Boolean(message);
React.useLayoutEffect(() => {
const node = group.current;
return () => {
if (node?.contains(document.activeElement)) focusAfterCommit(trigger);
};
}, [trigger]);
function commit() {
if (pending) return;
const next = draft.trim();
if (required && !next) {
setMissing(true);
return;
}
setPending(true);
new Promise<void>((resolve) => resolve(onCommit(next))).then(
() => {
setPending(false);
// A null ref means the editor closed while saving; don't close its successor.
if (group.current) onClose();
},
() => setPending(false)
);
}
function cancel() {
if (pending) return;
onClose();
}
function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
if (event.currentTarget.contains(event.relatedTarget)) return;
if (blurBehavior === 'save') commit();
if (blurBehavior === 'cancel') cancel();
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setDraft(event.target.value);
setMissing(false);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.nativeEvent.isComposing) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cancel();
return;
}
if (event.key !== 'Enter') return;
event.preventDefault();
commit();
}
return (
<div
ref={group}
role="group"
aria-label={`Edit ${label}`}
aria-busy={pending || undefined}
data-slot="inline-edit-editor"
className="nx:grid nx:gap-1"
onBlur={handleBlur}
>
<div className="nx:flex nx:items-center nx:gap-1">
<Input
ref={focusInput}
size="sm"
variant="ghost"
className={cn(alignWithText, 'nx:typography-body-default')}
aria-label={label}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? errorId : undefined}
required={required}
readOnly={pending}
placeholder={placeholder}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<div data-slot="inline-edit-actions" className="nx:flex nx:gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Save ${label}`}
title={`Save ${label}`}
onClick={commit}
>
<IconCheck
aria-hidden="true"
className="nx:text-success-subtle-foreground"
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing ${label}`}
title="Cancel editing"
onClick={cancel}
>
<IconX
aria-hidden="true"
className="nx:text-error-subtle-foreground"
/>
</Button>
</div>
</div>
{invalid && <FieldError id={errorId}>{message}</FieldError>}
</div>
);
}
interface InlineEditTriggerProps {
label: string;
ref: React.Ref<HTMLButtonElement>;
onClick: () => void;
children: React.ReactNode;
}
function InlineEditClickTrigger({
label,
children,
...props
}: InlineEditTriggerProps) {
return (
<button
type="button"
data-slot="inline-edit-trigger"
className={cn(
alignWithText,
'nx:flex nx:min-h-8 nx:w-full nx:cursor-pointer nx:items-center nx:rounded-md nx:border-default nx:border-transparent nx:bg-transparent nx:px-2.5 nx:py-0 nx:[font:inherit] nx:text-inherit nx:text-start nx:hover:bg-container-hover nx:active:bg-container-active nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default nx:focus-visible:outline-offset-2'
)}
{...props}
>
<span className="nx:sr-only">{`Edit ${label} `}</span>
{children}
</button>
);
}
function InlineEditPencilTrigger({
label,
children,
...props
}: InlineEditTriggerProps) {
return (
<div className="nx:flex nx:items-center nx:gap-2">
{children}
<Button
type="button"
variant="ghost"
size="icon-sm"
data-slot="inline-edit-trigger"
aria-label={`Edit ${label}`}
title={`Edit ${label}`}
{...props}
>
<IconPencil aria-hidden="true" className="nx:text-muted-foreground" />
</Button>
</div>
);
}
/**
* Edits one line of text in place. Your app owns the saved value, saving and
* any error. `editing` and `onEditingChange` control when the editor is open.
*/
export function InlineEdit({
value,
label,
onCommit,
editing: editingProp,
onEditingChange,
readOnly = false,
error,
activation = 'pencil',
blurBehavior = 'keep-open',
required = false,
requiredMessage = `${label} is required.`,
emptyText = 'Not provided',
placeholder,
className,
...props
}: InlineEditProps) {
const [uncontrolledEditing, setUncontrolledEditing] = React.useState(false);
const editing = !readOnly && (editingProp ?? uncontrolledEditing);
const trigger = React.useRef<HTMLButtonElement>(null);
const Trigger =
activation === 'click' ? InlineEditClickTrigger : InlineEditPencilTrigger;
const display = <InlineEditValue value={value} emptyText={emptyText} />;
function setEditing(next: boolean) {
if (editingProp === undefined) setUncontrolledEditing(next);
onEditingChange?.(next);
}
return (
<div
data-slot="inline-edit"
data-activation={activation}
data-editing={editing || undefined}
data-readonly={readOnly || undefined}
className={cn('nx:min-w-0 nx:typography-body-default', className)}
{...props}
>
{readOnly && display}
{editing && (
<InlineEditEditor
value={value}
label={label}
error={error}
blurBehavior={blurBehavior}
required={required}
requiredMessage={requiredMessage}
placeholder={placeholder}
onCommit={onCommit}
onClose={() => setEditing(false)}
trigger={trigger}
/>
)}
{!readOnly && !editing && (
<Trigger ref={trigger} label={label} onClick={() => setEditing(true)}>
{display}
</Trigger>
)}
</div>
);
}