Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 11 Components in Forms und Inputs

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

ProfilNeu in 1.2

Ein Profil, in dem jeder Wert direkt bearbeitet wird. 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.

ÜberschriftNeu in 1.2

Ein Dokumenttitel, der eine echte Überschrift bleibt. Das Feld übernimmt die Größe des Textes, nichts springt, und die Beschreibung darunter wird als längerer Text bearbeitet.

TabellenzellenNeu in 1.2

Zellen einer Preisliste, die sich direkt bearbeiten lassen, Zahlen rechtsbündig, Geld im Format der Sprache, Status als Auswahl. Jede Änderung zählt in die Summe darunter.

EinstellungszeilenNeu in 1.2

Zeilen mit Beschriftung und eigenem Knopf zum Bearbeiten, für Werte, die man selten und mit Absicht ändert. Speichern und Abbrechen stehen als Wörter da, die Formatprüfung erklärt sich selbst.

Details

Was Direkt bearbeiten mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/inline-edit.tsx
Bringt mit
utilskiticonbuttoninput
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 16 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle InlineEditProps aus components/ui/inline-edit.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • typeoptional
    InlineEditType

    textheadingtextareaselectnumbercurrency

  • valueoptional
    InlineEditValue
  • defaultValueoptional
    InlineEditValue
  • onSaveoptional
    (value: InlineEditValue) => SaveResult | Promise<SaveResult>

    Saves the value. May be async. A string answer is shown as the error and the field stays open.

  • labelPflicht
    string

    Accessible name, and the visible label with showLabel

  • showLabeloptional
    boolean
  • placeholderoptional
    string

    Shown in place of an empty value, e.g. "Add a short bio"

  • optionsoptional
    { value: string; label: string }[]
  • requiredoptional
    boolean
  • minLengthoptional
    number
  • maxLengthoptional
    number
  • minoptional
    number
  • maxoptional
    number
  • stepoptional
    number
  • patternoptional
    string

    A regular expression the text must match, e.g. ^[A-Z]{2}-\\d{4}$

  • patternMessageoptional
    string
  • validateoptional
    (value: InlineEditValue) => string | undefined

    Your own check, returns the error message or nothing

  • currencyoptional
    string

    For type currency, default EUR in German and USD in English

  • decimalsoptional
    number
  • leveloptional
    1 | 2 | 3 | 4

    For type heading

  • triggeroptional
    'click' | 'button'

    click: the text itself opens the field. button: text and a separate Edit button (settings rows).

  • actionsoptional
    boolean

    Save 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'
  • showKeysoptional
    boolean

    Show a hint with the keys while editing

  • disabledoptional
    boolean
  • nameoptional
    string

Eingebaute Texte

Diese Texte schreibt Direkt bearbeiten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 16 Texte anzeigen
editEnglisch EditDeutsch Bearbeiten
saveEnglisch SaveDeutsch Speichern
cancelEnglisch CancelDeutsch Abbrechen
savingEnglisch SavingDeutsch Wird gespeichert
savedEnglisch SavedDeutsch Gespeichert
failedEnglisch 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.
requiredEnglisch Please fill this in.Deutsch Bitte ausfüllen.
tooShortEnglisch Please use at least {n} characters.Deutsch Bitte mindestens {n} Zeichen.
tooLongEnglisch Please keep it to {n} characters.Deutsch Bitte höchstens {n} Zeichen.
tooSmallEnglisch The smallest value is {n}.Deutsch Der kleinste Wert ist {n}.
tooLargeEnglisch The largest value is {n}.Deutsch Der größte Wert ist {n}.
notNumberEnglisch Please enter a number.Deutsch Bitte eine Zahl eingeben.
patternEnglisch Please check the format.Deutsch Bitte das Format prüfen.
newlineEnglisch 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.
keysEnglisch Enter saves, Escape keeps the old value.Deutsch Enter speichert, Escape behält den alten Wert.
emptyEnglisch EmptyDeutsch 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
components/ui/inline-edit.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert