Inline Bearbeitung
Auf einen Wert klicken und ihn an Ort und Stelle ändern. Text, Überschriften, längere Texte, Auswahl, Zahlen und Geld. Enter speichert, Escape behält den alten Wert, Prüfungen melden sich unter dem Feld, das Speichern zeigt seinen Stand.
Einen Wert genau dort ändern, wo er steht
Mit der Inline Bearbeitung klicken Menschen einen Wert an und ändern ihn direkt, ohne Formular und ohne eigene Seite. Enter speichert, Escape stellt wieder her, Prüfungen erklären, was fehlt, und die Zustände speichert und gespeichert werden per aria-live angesagt, bevor der Fokus zum Text zurückkehrt.
„Profil“ bearbeitet jeden Wert direkt, der Name speichert nach einer kurzen Anfrage, die Rolle mit der Auswahl, die Adresse wird geprüft und der Server kann sie ablehnen, der Text über dich zählt seine Zeichen. „Überschrift“ hält einen Dokumenttitel beim Bearbeiten als echte Überschrift, das Feld übernimmt die Größe des Textes, nichts springt. „Tabellenzellen“ bearbeitet eine Preisliste direkt, Zahlen rechtsbündig, Geld im Format der Sprache, Status als Auswahl, jede Änderung zählt in die Summe darunter. „Einstellungszeilen“ geben jedem Wert eine Beschriftung und einen eigenen Knopf zum Bearbeiten, für Werte, die man selten und mit Absicht ändert.
Nutze die Inline Bearbeitung für Werte, die Menschen oft und schnell ändern, Namen, Titel, Preise. Zeig beim Zeigen und Fokussieren einen klaren Hinweis, sonst weiß niemand, dass der Text bearbeitbar ist. Speichere bei Enter und beim Verlassen des Felds.
Variants
Details
Was Direkt bearbeiten mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Forms und Inputs
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/inline-edit.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 16 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle InlineEditProps aus components/ui/inline-edit.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
typeoptionalInlineEditTypetextheadingtextareaselectnumbercurrency
valueoptionalInlineEditValuedefaultValueoptionalInlineEditValueonSaveoptional(value: InlineEditValue) => SaveResult | Promise<SaveResult>Saves the value. May be async. A string answer is shown as the error and the field stays open.
labelPflichtstringAccessible name, and the visible label with showLabel
showLabeloptionalbooleanplaceholderoptionalstringShown in place of an empty value, e.g. "Add a short bio"
optionsoptional{ value: string; label: string }[]requiredoptionalbooleanminLengthoptionalnumbermaxLengthoptionalnumberminoptionalnumbermaxoptionalnumberstepoptionalnumberpatternoptionalstringA regular expression the text must match, e.g. ^[A-Z]{2}-\\d{4}$
patternMessageoptionalstringvalidateoptional(value: InlineEditValue) => string | undefinedYour own check, returns the error message or nothing
currencyoptionalstringFor type currency, default EUR in German and USD in English
decimalsoptionalnumberleveloptional1 | 2 | 3 | 4For type heading
triggeroptional'click' | 'button'click: the text itself opens the field. button: text and a separate Edit button (settings rows).
actionsoptionalbooleanSave and cancel buttons while editing, default on
onBlurActionoptional'save' | 'cancel'What leaving the field does
sizeoptional'sm' | 'md'sm for table cells
alignoptional'start' | 'end'showKeysoptionalbooleanShow a hint with the keys while editing
disabledoptionalbooleannameoptionalstring
Eingebaute Texte
Diese Texte schreibt Direkt bearbeiten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 16 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| edit | Englisch Edit | Deutsch Bearbeiten |
| save | Englisch Save | Deutsch Speichern |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| saving | Englisch Saving | Deutsch Wird gespeichert |
| saved | Englisch Saved | Deutsch Gespeichert |
| failed | Englisch That did not save. Your text is still here, try again. | Deutsch Das Speichern hat nicht geklappt. Dein Text ist noch da, versuche es gleich noch einmal. |
| required | Englisch Please fill this in. | Deutsch Bitte ausfüllen. |
| tooShort | Englisch Please use at least {n} characters. | Deutsch Bitte mindestens {n} Zeichen. |
| tooLong | Englisch Please keep it to {n} characters. | Deutsch Bitte höchstens {n} Zeichen. |
| tooSmall | Englisch The smallest value is {n}. | Deutsch Der kleinste Wert ist {n}. |
| tooLarge | Englisch The largest value is {n}. | Deutsch Der größte Wert ist {n}. |
| notNumber | Englisch Please enter a number. | Deutsch Bitte eine Zahl eingeben. |
| pattern | Englisch Please check the format. | Deutsch Bitte das Format prüfen. |
| newline | Englisch Enter saves, Shift and Enter start a new line, Escape keeps the old text. | Deutsch Enter speichert, Umschalt und Enter beginnen eine neue Zeile, Escape behält den alten Text. |
| keys | Englisch Enter saves, Escape keeps the old value. | Deutsch Enter speichert, Escape behält den alten Wert. |
| empty | Englisch Empty | Deutsch Leer |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Direkt bearbeiten reagiert selbst auf die Tasten Escape, Enter und Leertaste.
- Setzt aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle alert.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 522 Zeilen
'use client';
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Button } from '@/components/ui/button';
import { useShake } from '@/components/ui/input';
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const TEXTS = {
en: {
edit: 'Edit',
save: 'Save',
cancel: 'Cancel',
saving: 'Saving',
saved: 'Saved',
failed: 'That did not save. Your text is still here, try again.',
required: 'Please fill this in.',
tooShort: 'Please use at least {n} characters.',
tooLong: 'Please keep it to {n} characters.',
tooSmall: 'The smallest value is {n}.',
tooLarge: 'The largest value is {n}.',
notNumber: 'Please enter a number.',
pattern: 'Please check the format.',
newline: 'Enter saves, Shift and Enter start a new line, Escape keeps the old text.',
keys: 'Enter saves, Escape keeps the old value.',
empty: 'Empty',
},
de: {
edit: 'Bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Wird gespeichert',
saved: 'Gespeichert',
failed: 'Das Speichern hat nicht geklappt. Dein Text ist noch da, versuche es gleich noch einmal.',
required: 'Bitte ausfüllen.',
tooShort: 'Bitte mindestens {n} Zeichen.',
tooLong: 'Bitte höchstens {n} Zeichen.',
tooSmall: 'Der kleinste Wert ist {n}.',
tooLarge: 'Der größte Wert ist {n}.',
notNumber: 'Bitte eine Zahl eingeben.',
pattern: 'Bitte das Format prüfen.',
newline: 'Enter speichert, Umschalt und Enter beginnen eine neue Zeile, Escape behält den alten Text.',
keys: 'Enter speichert, Escape behält den alten Wert.',
empty: 'Leer',
},
};
export type InlineEditType = 'text' | 'heading' | 'textarea' | 'select' | 'number' | 'currency';
export type InlineEditValue = string | number;
/** What onSave may answer: nothing or true (saved), a string (shown as the error), false (the general error) */
export type SaveResult = void | boolean | string;
export interface InlineEditProps {
type?: InlineEditType;
value?: InlineEditValue;
defaultValue?: InlineEditValue;
/** Saves the value. May be async. A string answer is shown as the error and the field stays open. */
onSave?: (value: InlineEditValue) => SaveResult | Promise<SaveResult>;
/** Accessible name, and the visible label with showLabel */
label: string;
showLabel?: boolean;
/** Shown in place of an empty value, e.g. "Add a short bio" */
placeholder?: string;
options?: { value: string; label: string }[];
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
step?: number;
/** A regular expression the text must match, e.g. ^[A-Z]{2}-\\d{4}$ */
pattern?: string;
patternMessage?: string;
/** Your own check, returns the error message or nothing */
validate?: (value: InlineEditValue) => string | undefined;
/** For type currency, default EUR in German and USD in English */
currency?: string;
decimals?: number;
/** For type heading */
level?: 1 | 2 | 3 | 4;
/** click: the text itself opens the field. button: text and a separate Edit button (settings rows). */
trigger?: 'click' | 'button';
/** Save and cancel buttons while editing, default on */
actions?: boolean;
/** What leaving the field does */
onBlurAction?: 'save' | 'cancel';
/** sm for table cells */
size?: 'sm' | 'md';
align?: 'start' | 'end';
/** Show a hint with the keys while editing */
showKeys?: boolean;
disabled?: boolean;
name?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, n: number | string) => s.replace('{n}', String(n));
function parseNumber(text: string, locale: Locale) {
const t = text.trim();
if (!t) return null;
const dec = locale === 'de' ? ',' : '.';
const clean = t.replace(new RegExp(`[^0-9\\-${dec === ',' ? ',' : '.'}]`, 'g'), '').replace(dec, '.');
const n = Number.parseFloat(clean);
return Number.isFinite(n) ? n : null;
}
/**
* Click to edit in place after the pattern of a button that turns into a field. Text, heading,
* multiline text, select, number and money, in a form or a table cell. Enter saves, Escape keeps
* the old value, checks show their message under the field, saving shows its state and is
* announced, focus comes back to the text.
* <InlineEdit label="Name" defaultValue="Mia Hoffmann" required onSave={async (v) => save(v)} />
*/
export function InlineEdit({
type = 'text',
value: valueProp,
defaultValue,
onSave,
label,
showLabel,
placeholder,
options,
required,
minLength,
maxLength,
min,
max,
step,
pattern,
patternMessage,
validate,
currency,
decimals,
level = 2,
trigger = 'click',
actions = true,
onBlurAction = 'save',
size = 'md',
align = 'start',
showKeys,
disabled,
name,
labels,
className,
}: InlineEditProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const numeric = type === 'number' || type === 'currency';
const [inner, setInner] = useState<InlineEditValue>(defaultValue ?? (numeric ? 0 : ''));
const value = valueProp ?? inner;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [pending, setPending] = useState<InlineEditValue | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement & HTMLSelectElement>(null);
const viewRef = useRef<HTMLButtonElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const backToView = useRef(false);
useShake(boxRef, Boolean(error));
const money = (n: number) =>
new Intl.NumberFormat(TAG[locale], { style: 'currency', currency: currency ?? (locale === 'de' ? 'EUR' : 'USD'), minimumFractionDigits: decimals ?? 2, maximumFractionDigits: decimals ?? 2 }).format(n);
const display = (v: InlineEditValue) => {
if (type === 'select') return options?.find((o) => o.value === String(v))?.label ?? String(v);
if (type === 'currency') return money(Number(v));
if (type === 'number') return new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: decimals ?? 2 }).format(Number(v));
return String(v);
};
const toDraft = (v: InlineEditValue) => (numeric ? new Intl.NumberFormat(TAG[locale], { minimumFractionDigits: type === 'currency' ? decimals ?? 2 : 0, maximumFractionDigits: decimals ?? 2, useGrouping: false }).format(Number(v)) : String(v));
const open = () => {
if (disabled || status === 'saving') return;
setDraft(toDraft(value));
setError(null);
setEditing(true);
};
// Focus the field and select its text once it is there
useIsoLayoutEffect(() => {
if (!editing) return;
const f = fieldRef.current;
f?.focus();
if (f && 'select' in f && type !== 'select') f.select();
}, [editing, type]);
// Focus returns to the text after editing
useEffect(() => {
if (!editing && backToView.current) {
backToView.current = false;
(viewRef.current ?? wrapRef.current?.querySelector<HTMLElement>('[data-ie-open]'))?.focus();
}
}, [editing]);
useEffect(() => {
if (status !== 'saved') return;
const timer = window.setTimeout(() => setStatus('idle'), 2400);
return () => window.clearTimeout(timer);
}, [status]);
const check = (raw: string): { ok: true; value: InlineEditValue } | { ok: false; message: string } => {
const text = type === 'textarea' ? raw.replace(/\s+$/, '') : raw.trim();
if (!text && required) return { ok: false, message: t.required };
if (numeric) {
if (!text && !required) return { ok: true, value: 0 };
const n = parseNumber(text, locale);
if (n === null) return { ok: false, message: t.notNumber };
const shown = (x: number) => (type === 'currency' ? money(x) : new Intl.NumberFormat(TAG[locale]).format(x));
if (min !== undefined && n < min) return { ok: false, message: fill(t.tooSmall, shown(min)) };
if (max !== undefined && n > max) return { ok: false, message: fill(t.tooLarge, shown(max)) };
const own = validate?.(n);
return own ? { ok: false, message: own } : { ok: true, value: n };
}
if (minLength !== undefined && text.length < minLength && (text || required)) return { ok: false, message: fill(t.tooShort, minLength) };
if (maxLength !== undefined && text.length > maxLength) return { ok: false, message: fill(t.tooLong, maxLength) };
if (pattern && text && !new RegExp(pattern).test(text)) return { ok: false, message: patternMessage ?? t.pattern };
const own = validate?.(text);
return own ? { ok: false, message: own } : { ok: true, value: text };
};
const cancel = (focusBack = true) => {
backToView.current = focusBack;
setError(null);
setEditing(false);
};
const save = async (raw = draft, focusBack = true) => {
const res = check(raw);
if (!res.ok) {
setError(res.message);
fieldRef.current?.focus();
return;
}
setError(null);
if (res.value === value) return cancel(focusBack);
backToView.current = focusBack;
setEditing(false);
if (!onSave) {
if (valueProp === undefined) setInner(res.value);
setStatus('saved');
return;
}
// The new value shows right away, dimmed, until the save answers
setPending(res.value);
setStatus('saving');
let answer: SaveResult;
try {
answer = await onSave(res.value);
} catch {
answer = false;
}
setPending(null);
if (answer === false || typeof answer === 'string') {
setStatus('idle');
setDraft(raw);
setError(typeof answer === 'string' ? answer : t.failed);
setEditing(true);
return;
}
if (valueProp === undefined) setInner(res.value);
setStatus('saved');
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancel();
} else if (e.key === 'Enter' && !(type === 'textarea' && e.shiftKey) && !e.nativeEvent.isComposing) {
e.preventDefault();
save();
}
};
const onBlur = (e: React.FocusEvent) => {
// Moving to the save or cancel button is not leaving
if (wrapRef.current?.contains(e.relatedTarget as Node)) return;
if (onBlurAction === 'cancel') return cancel(false);
// Leaving the page or the window keeps the field as it is
if (!e.relatedTarget && !document.hasFocus()) return;
save(draft, false);
};
const shownValue = pending ?? value;
const isEmpty = String(shownValue) === '' && !numeric;
const Heading = `h${level}` as 'h2';
const text = {
heading: { 1: 'text-h1', 2: 'text-h2', 3: 'text-h3', 4: 'text-lg' }[level] + ' font-semibold tracking-[-0.02em]',
textarea: 'text-[15px] leading-relaxed',
other: size === 'sm' ? 'text-sm' : 'text-[15px]',
};
const typo = type === 'heading' ? text.heading : type === 'textarea' ? text.textarea : text.other;
// One pixel less than the text above it, the border makes up for it and nothing jumps
const pad = trigger === 'button' ? 'px-3 py-2' : size === 'sm' ? 'px-2 py-[5px]' : type === 'heading' ? 'px-2 py-[3px]' : 'px-2 py-[7px]';
const describedBy = cn(error && `${id}-e`, editing && showKeys && `${id}-k`) || undefined;
const affix = type === 'currency' ? currencyAffix(locale, currency) : {};
/* ---------- the field ---------- */
const fieldClass = cn('w-full min-w-0 bg-transparent text-foreground outline-none placeholder:text-subtle-foreground [font:inherit] [letter-spacing:inherit]', align === 'end' && 'text-right');
const field =
type === 'select' ? (
<select
ref={fieldRef}
id={`${id}-f`}
name={name}
value={draft}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
onChange={(e) => {
setDraft(e.target.value);
if (!actions) save(e.target.value);
}}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={cn(fieldClass, 'cursor-pointer appearance-none pe-7')}
>
{options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : type === 'textarea' ? (
<textarea
ref={fieldRef}
id={`${id}-f`}
name={name}
value={draft}
rows={3}
maxLength={maxLength ? maxLength + 40 : undefined}
placeholder={placeholder}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={cn(fieldClass, 'block resize-none [field-sizing:content] min-h-[4.5lh]')}
/>
) : (
<input
ref={fieldRef}
id={`${id}-f`}
name={name}
value={draft}
inputMode={numeric ? 'decimal' : undefined}
placeholder={placeholder}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={fieldClass}
/>
);
const count = maxLength && (type === 'text' || type === 'textarea' || type === 'heading') && editing && (
<span aria-hidden className={cn('text-xs tabular-nums', draft.length > maxLength ? 'text-destructive' : 'text-subtle-foreground')}>
{draft.length}/{maxLength}
</span>
);
const editor = (
<div className="grid min-w-0 gap-1.5">
<div
ref={boxRef}
className={cn(
'relative flex min-w-0 items-center gap-1 rounded-md border bg-background shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] transition-[border-color,box-shadow] duration-200',
type === 'textarea' && 'items-start',
error ? 'border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_16%,transparent)]' : 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]',
trigger === 'click' && '-mx-[calc(0.5rem+1px)]',
pad,
typo,
)}
>
{affix.prefix && <span className="shrink-0 text-muted-foreground">{affix.prefix}</span>}
{field}
{affix.suffix && <span className="shrink-0 text-muted-foreground">{affix.suffix}</span>}
{type === 'select' && <Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />}
</div>
{(error || count || (actions && trigger === 'click')) && (
<div className={cn('flex min-w-0 items-start gap-3', trigger === 'click' && '-mx-[calc(0.5rem+1px)]')}>
{error ? (
<p id={`${id}-e`} role="alert" className="flex min-w-0 flex-1 items-start gap-1.5 text-[13px] font-normal leading-snug tracking-normal text-destructive">
<Icon name="alert-circle" size={15} className="mt-px" />
{error}
</p>
) : (
<span className="flex-1" />
)}
{count}
{actions && trigger === 'click' && (
<span className="flex shrink-0 gap-1">
<IconAction label={t.cancel} icon="x" onClick={() => cancel()} />
<IconAction label={t.save} icon="check" onClick={() => save()} primary />
</span>
)}
</div>
)}
{showKeys && (
<p id={`${id}-k`} className="text-xs font-normal tracking-normal text-subtle-foreground max-sm:hidden">
{type === 'textarea' ? t.newline : t.keys}
</p>
)}
</div>
);
/* ---------- the text ---------- */
const valueText = (
<span className={cn('min-w-0 whitespace-pre-line break-words', isEmpty && 'text-subtle-foreground', status === 'saving' && 'opacity-60', align === 'end' && 'text-right')}>
{isEmpty ? placeholder ?? t.empty : display(shownValue)}
</span>
);
const statusMark = (
<span aria-hidden className={cn('inline-flex shrink-0 items-center gap-1 self-center text-xs font-medium tracking-normal transition-opacity duration-300', status === 'idle' ? 'hidden' : 'kit-rise', status === 'saved' ? 'text-success-foreground' : 'text-muted-foreground')}>
{status === 'saving' ? <Icon name="loader" size={14} className="animate-spin motion-reduce:animate-none" /> : <Icon name="check" size={14} strokeWidth={2.2} />}
<span className={cn(size === 'sm' && 'sr-only')}>{status === 'saving' ? t.saving : t.saved}</span>
</span>
);
const view =
trigger === 'click' ? (
<button
ref={viewRef}
type="button"
data-ie-open=""
disabled={disabled}
onClick={open}
className={cn(
'group/ie relative -mx-2 flex w-[calc(100%+1rem)] min-w-0 cursor-pointer items-start gap-2 rounded-md text-left text-foreground transition-colors duration-200',
'hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] disabled:cursor-default disabled:hover:bg-transparent',
'outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-ring',
size === 'sm' ? 'min-h-11 px-2 py-1.5 sm:min-h-9' : type === 'heading' ? 'px-2 py-1' : 'min-h-11 px-2 py-2',
align === 'end' && 'justify-end',
typo,
)}
>
{valueText}
<span className="sr-only">, {showLabel ? t.edit : `${label} ${t.edit.toLowerCase()}`}</span>
<Icon
name="edit"
size={size === 'sm' ? 14 : 15}
className={cn('shrink-0 self-center text-subtle-foreground transition-opacity duration-200 group-hover/ie:opacity-100 group-focus-visible/ie:opacity-100 sm:opacity-0', status !== 'idle' && 'hidden', disabled && 'hidden')}
/>
{statusMark}
</button>
) : (
<div className={cn('flex min-w-0 items-center gap-3', typo)}>
<span className="min-w-0 flex-1 text-foreground">{valueText}</span>
{statusMark}
<Button variant="ghost" size="sm" iconLeft="edit" data-ie-open="" disabled={disabled} onClick={open} aria-label={`${label} ${t.edit.toLowerCase()}`} className="shrink-0 -me-2">
{t.edit}
</Button>
</div>
);
return (
<div ref={wrapRef} className={cn('grid min-w-0 gap-1', className)}>
{showLabel && (
editing ? (
<label htmlFor={`${id}-f`} className="text-[13px] font-medium text-muted-foreground">
{label}
</label>
) : (
<span className="text-[13px] font-medium text-muted-foreground">{label}</span>
)
)}
{!showLabel && editing && (
<label htmlFor={`${id}-f`} className="sr-only">
{label}
</label>
)}
{type === 'heading' ? <Heading className="min-w-0">{editing ? editor : view}</Heading> : editing ? editor : view}
{editing && trigger === 'button' && (
<div className="mt-1 flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => cancel()} className="max-sm:flex-1">
{t.cancel}
</Button>
<Button size="sm" onClick={() => save()} className="max-sm:flex-1">
{t.save}
</Button>
</div>
)}
<span aria-live="polite" className="sr-only">
{status === 'saving' ? `${label}, ${t.saving}` : status === 'saved' ? `${label}, ${t.saved}` : ''}
</span>
{name && !editing && <input type="hidden" name={name} value={String(value)} />}
</div>
);
}
function IconAction({ label, icon, onClick, primary }: { label: string; icon: 'x' | 'check'; onClick: () => void; primary?: boolean }) {
return (
<button
type="button"
aria-label={label}
title={label}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={cn(
'relative grid size-7 cursor-pointer place-items-center rounded-md border shadow-[var(--shadow-sm)] transition-colors duration-200 after:absolute after:-inset-2 sm:after:-inset-1',
'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
primary ? 'border-primary bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))]' : 'border-border-strong bg-background text-muted-foreground hover:text-foreground',
)}
>
<Icon name={icon} size={15} strokeWidth={2} />
</button>
);
}
function currencyAffix(locale: Locale, currency?: string): { prefix?: string; suffix?: string } {
const parts = new Intl.NumberFormat(TAG[locale], { style: 'currency', currency: currency ?? (locale === 'de' ? 'EUR' : 'USD') }).formatToParts(1);
const sym = parts.find((p) => p.type === 'currency')?.value ?? '';
return parts.findIndex((p) => p.type === 'currency') < parts.findIndex((p) => p.type === 'integer') ? { prefix: sym } : { suffix: sym };
}Häufige Fragen
Wie installiere ich Direkt bearbeiten?
Mit aiw add @ai-website/inline-edit. Der Befehl legt die Datei components/ui/inline-edit.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, button und input. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Direkt bearbeiten?
4 Varianten, Profil, Überschrift, Tabellenzellen und Einstellungszeilen.
Kann ich die Texte von Direkt bearbeiten übersetzen?
Ja. Direkt bearbeiten bringt 16 eigene Texte auf Deutsch und Englisch mit, etwa „Bearbeiten“, „Speichern“ und „Abbrechen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Direkt bearbeiten einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Direkt bearbeiten mit aiw_components und installieren sie mit aiw_add @ai-website/inline-edit.