Kontroller
Inline edit
Tekst, der bliver til et felt på stedet. Enter gemmer, Escape fortryder.
Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.
Forhåndsvisning
Brug
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>
)
}Kilde
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>
)
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/inline-edit.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/inline-edit