Inline Edit

Click to edit text in place. Enter saves, Escape cancels, async saves supported.

@drift-ui/inline-editRegistry JSON
Project
Owner
On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

API reference

InlineEdit

Text that turns into an input on click. Enter or blur saves and Escape cancels. Pass aria-label to describe the field.

Prop
valueRequiredstringThe saved text.
onSaveRequired(value: string) => void | Promise<void>Called with the edited text. While a returned promise is pending the field is read-only, and a rejection marks it invalid and keeps it open.
placeholderstring= "Empty"Text shown while value is empty.
disabledbooleanPrevents editing.

Other props go to the <div>.

Source

components/ui/inline-edit.tsx
"use client"

import * as React from "react"
import { PencilIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function InlineEdit({
  value,
  onSave,
  placeholder = "Empty",
  disabled,
  className,
  "aria-label": ariaLabel,
  ...props
}: Omit<React.ComponentProps<"div">, "onChange"> & {
  value: string
  onSave: (value: string) => void | Promise<void>
  placeholder?: string
  disabled?: boolean
}) {
  const [isEditing, setIsEditing] = React.useState(false)
  const [draft, setDraft] = React.useState(value)
  const [hasFailed, setHasFailed] = React.useState(false)
  const [isPending, startTransition] = React.useTransition()
  const shouldRefocusTriggerRef = React.useRef(false)

  function startEditing() {
    setDraft(value)
    setHasFailed(false)
    setIsEditing(true)
  }

  function stopEditing() {
    shouldRefocusTriggerRef.current = true
    setIsEditing(false)
  }

  function save() {
    if (draft === value) return stopEditing()
    startTransition(async () => {
      try {
        await onSave(draft)
        stopEditing()
      } catch {
        setHasFailed(true)
      }
    })
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    switch (event.key) {
      case "Enter":
        event.preventDefault()
        save()
        return
      case "Escape":
        event.preventDefault()
        stopEditing()
        return
    }
  }

  return (
    <div
      data-slot="inline-edit"
      data-editing={isEditing || undefined}
      className={cn("group/inline-edit relative w-full min-w-0", className)}
      {...props}
    >
      {isEditing ? (
        <input
          autoFocus
          data-slot="inline-edit-input"
          aria-label={ariaLabel}
          aria-invalid={hasFailed || undefined}
          aria-busy={isPending || undefined}
          value={draft}
          readOnly={isPending}
          placeholder={placeholder}
          onFocus={(event) => event.currentTarget.select()}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={handleKeyDown}
          onBlur={() => {
            if (!isPending) save()
          }}
          className={cn(
            "-mx-2 h-8 w-[calc(100%+1rem)] rounded-md border border-input bg-transparent px-2 text-sm shadow-xs outline-none dark:bg-input/30",
            "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
            "aria-busy:opacity-60 aria-invalid:border-destructive aria-invalid:ring-destructive/20"
          )}
        />
      ) : (
        <button
          ref={(node) => {
            if (!node || !shouldRefocusTriggerRef.current) return
            shouldRefocusTriggerRef.current = false
            node.focus()
          }}
          type="button"
          data-slot="inline-edit-trigger"
          aria-label={ariaLabel ? `Edit ${ariaLabel}` : undefined}
          disabled={disabled}
          onClick={startEditing}
          className={cn(
            "-mx-2 flex h-8 w-[calc(100%+1rem)] items-center justify-between gap-2 rounded-md px-2 text-left text-sm outline-none",
            "hover:bg-accent/60 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none"
          )}
        >
          <span className={cn("truncate", !value && "text-muted-foreground")}>
            {value || placeholder}
          </span>
          <PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within/inline-edit:opacity-100 group-hover/inline-edit:opacity-100" />
        </button>
      )}
    </div>
  )
}

export { InlineEdit }