Mikkel Manniche
Dansk English Deutsch

Bedienelemente

Inline edit

Text, der sich an Ort und Stelle in ein Feld verwandelt. Enter speichert, Escape bricht ab.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

inline-edit-demo.tsx
import { CalendarDays, Clock, MapPin, NotebookText, Tag } from 'lucide-react'
import { useState } from 'react'
import { InlineEdit } from '@/components/inline-edit'

export default function InlineEditDemo() {
  const [event, setEvent] = useState({
    name: 'Spring sample sale',
    date: 'Saturday 14 March',
    time: '10:00 – 16:00',
    place: 'Back room, Main Street store',
    notes: 'Members get in at 9.\nCards and cash.',
  })
  const set = (key: keyof typeof event) => (v: string) => setEvent((e) => ({ ...e, [key]: v }))

  return (
    <div className="mx-auto w-full max-w-md rounded-[28px] border bg-muted/50 p-1.5 shadow-sm">
      <div className="overflow-hidden rounded-[22px] border bg-card">
        <p className="border-b bg-muted/40 px-5 py-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">Event details</p>
        <div className="space-y-2 p-3 sm:space-y-1">
          <InlineEdit label="Event" icon={<Tag />} value={event.name} onSave={set('name')} />
          <InlineEdit label="Date" icon={<CalendarDays />} value={event.date} onSave={set('date')} />
          <InlineEdit label="Time" icon={<Clock />} value={event.time} onSave={set('time')} />
          <InlineEdit label="Place" icon={<MapPin />} value={event.place} onSave={set('place')} />
          <InlineEdit label="Notes" icon={<NotebookText />} value={event.notes} onSave={set('notes')} multiline />
        </div>
      </div>
    </div>
  )
}

Quellcode

inline-edit.tsx
// Based on Watermelon UI's “Inline edit” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as one reusable field: Enter saves, Esc cancels, and cancel restores the value.
import { Check, Pencil, X } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type InlineEditProps = {
  label: string
  value: string
  onSave: (value: string) => void
  icon?: ReactNode
  multiline?: boolean
  /** Shown when the value is empty. */
  placeholder?: string
  editLabel?: string
  saveLabel?: string
  cancelLabel?: string
  className?: string
}

/** A value that reads like text until you edit it. The pencil swaps for save and cancel. */
export function InlineEdit({
  label,
  value,
  onSave,
  icon,
  multiline = false,
  placeholder = 'Empty',
  editLabel = 'Edit',
  saveLabel = 'Save',
  cancelLabel = 'Cancel',
  className,
}: InlineEditProps) {
  const [editing, setEditing] = useState(false)
  const [draft, setDraft] = useState(value)
  const field = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
  const pencil = useRef<HTMLButtonElement>(null)
  const id = useId()

  useEffect(() => {
    if (editing) field.current?.select()
  }, [editing])

  const start = () => {
    setDraft(value)
    setEditing(true)
  }
  const stop = (save: boolean) => {
    if (save && draft.trim() !== value) onSave(draft.trim())
    setEditing(false)
    requestAnimationFrame(() => pencil.current?.focus())
  }

  const swap = { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: 24 } }
  const Field = multiline ? 'textarea' : 'input'

  return (
    <MotionConfig reducedMotion="user" transition={{ duration: 0.35, ease: [0.19, 1, 0.22, 1] }}>
      <div className={cn('flex flex-col gap-1 sm:flex-row sm:gap-4', multiline ? 'sm:items-start' : 'sm:items-center', className)}>
        <label htmlFor={editing ? id : undefined} className="flex shrink-0 items-center gap-2.5 text-sm text-muted-foreground sm:w-32 sm:py-2.5 [&_svg]:size-4.5">
          {icon}
          {label}
        </label>
        <div
          className={cn(
            'group flex min-h-11 w-full min-w-0 gap-2 overflow-hidden rounded-xl px-3 transition-colors duration-150',
            editing ? 'bg-muted' : 'hover:bg-muted/60',
            multiline ? 'items-start py-2' : 'items-center',
          )}
        >
          {editing ? (
            <Field
              ref={field}
              id={id}
              value={draft}
              rows={multiline ? 3 : undefined}
              onChange={(e) => setDraft(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Escape') stop(false)
                if (e.key === 'Enter' && (!multiline || e.metaKey || e.ctrlKey)) {
                  e.preventDefault()
                  stop(true)
                }
              }}
              className="w-full min-w-0 resize-none bg-transparent font-medium outline-none"
            />
          ) : (
            <p className={cn('w-full min-w-0 font-medium', multiline ? 'whitespace-pre-line' : 'truncate', !value && 'text-muted-foreground')}>
              {value || placeholder}
            </p>
          )}
          <div className="flex shrink-0 items-center">
            <AnimatePresence mode="popLayout" initial={false}>
              {editing ? (
                <motion.div key="edit" {...swap} className="flex gap-1">
                  <button
                    type="button"
                    aria-label={saveLabel}
                    onClick={() => stop(true)}
                    className="grid size-8 place-items-center rounded-lg bg-foreground text-background active:scale-95"
                  >
                    <Check className="size-4" aria-hidden />
                  </button>
                  <button
                    type="button"
                    aria-label={cancelLabel}
                    onClick={() => stop(false)}
                    className="grid size-8 place-items-center rounded-lg bg-card text-foreground shadow-sm active:scale-95"
                  >
                    <X className="size-4" aria-hidden />
                  </button>
                </motion.div>
              ) : (
                <motion.button
                  key="view"
                  ref={pencil}
                  {...swap}
                  type="button"
                  aria-label={`${editLabel} ${label.toLowerCase()}`}
                  onClick={start}
                  className="grid size-8 place-items-center rounded-lg border bg-card text-muted-foreground opacity-100 shadow-sm transition-opacity duration-150 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
                >
                  <Pencil className="size-3.5" aria-hidden />
                </motion.button>
              )}
            </AnimatePresence>
          </div>
        </div>
      </div>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/inline-edit.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/inline-edit

Dateien

  • inline-edit.tsx

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.