Zum Inhalt springen
UI Kit
Accent

Zahlenfeld

Ein Zahlenfeld nach dem Spinbutton-Muster, in vier Formen. Es zählt in Schritten, hält seine Grenzen, wiederholt beim Gedrückthalten und liest das Zahlenformat der Sprache.

Eine von 7 Components in Picker und Auswahlfelder

4 Variants

Variants

Stepper

Knöpfe übereinander am Ende, auf dem Telefon zwei breite nebeneinander. Pfeiltasten gehen einen Schritt, Umschalt oder Bildtasten zehn, Pos1 und Ende springen an die Grenzen. Die Summe folgt jeder Änderung.

Im Studio öffnen

Geteilt

Minus und Plus an beiden Seiten, für Anzahlen, die man lieber antippt als eintippt. Die Grenzen hängen voneinander ab, der Balken zeigt die belegten Betten.

Im Studio öffnen

Menge im Warenkorb

Kompakt für Warenkorbzeilen. Am Minimum wird das Minus zum Papierkorb, eine entfernte Zeile hinterlässt ein Rückgängig, und die Zwischensumme zählt mit.

Im Studio öffnen

Ziehen

Das Feld aus Gestaltungswerkzeugen. Den Buchstaben oder den Namen seitlich ziehen ändert den Wert, mit Umschalt in großen, mit Alt in feinen Schritten, oder einfach tippen. Eine Haarlinie zeigt, wo der Wert in seinem Bereich steht, die Vorschau folgt sofort.

Im Studio öffnen

Details

Was Zahlenfeld mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/number-input.tsx
Bringt mit
utilskiticoninput
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 4 eigene Texte

Props

Die Schnittstelle NumberInputProps aus components/ui/number-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.

  • variantoptional
    NumberInputVariant

    steppersplitquantityscrub

  • valueoptional
    number
  • defaultValueoptional
    number
  • onValueChangeoptional
    (value: number) => void
  • minoptional
    number
  • maxoptional
    number
  • stepoptional
    number
  • bigStepoptional
    number

    Step for Page Up, Page Down and Shift with an arrow. Default 10 steps.

  • decimalsoptional
    number

    Digits after the decimal mark. Default from the step.

  • prefixoptional
    string

    Quiet text before the value, e.g. "$"

  • unitoptional
    string

    Quiet text after the value, e.g. "px", "€" or "kg"

  • groupingoptional
    boolean

    Group thousands (1,280). Default true.

  • handleoptional
    string

    scrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box.

  • handleIconoptional
    IconName

    scrub: an icon at the start that you drag, instead of the letter

  • onRemoveoptional
    () => void

    quantity: at the minimum the minus turns into a remove button

  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • aria-labeloptional
    string

    Accessible name when there is no visible label

Eingebaute Texte

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

Alle 4 Texte anzeigen
increaseEnglisch IncreaseDeutsch Erhöhen
decreaseEnglisch DecreaseDeutsch Verringern
removeEnglisch RemoveDeutsch Entfernen
scrubEnglisch Drag sideways to changeDeutsch Seitlich ziehen zum Ändern

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Zahlenfeld reagiert selbst auf die Tasten Escape, Enter und Leertaste.
  • Setzt aria-controls, aria-describedby, aria-invalid, aria-label, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle spinbutton.
  • 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

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 440 Zeilen
components/ui/number-input.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';

/*
 * A number field after the spin button pattern (WAI-ARIA APG): the field
 * holds the value, arrows step, Page Up and Page Down take big steps, Home
 * and End jump to the limits. Typed text is read in the format of the
 * language (1.280,5 or 1,280.5) and snapped to the step on Enter or blur.
 * Four looks: buttons stacked at the end, buttons on both sides, a compact
 * cart quantity, and a design tool field you drag sideways.
 */

const TEXTS = {
  en: { increase: 'Increase', decrease: 'Decrease', remove: 'Remove', scrub: 'Drag sideways to change' },
  de: { increase: 'Erhöhen', decrease: 'Verringern', remove: 'Entfernen', scrub: 'Seitlich ziehen zum Ändern' },
};

export type NumberInputVariant = 'stepper' | 'split' | 'quantity' | 'scrub';

export interface NumberInputProps extends FieldOwnProps {
  variant?: NumberInputVariant;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Step for Page Up, Page Down and Shift with an arrow. Default 10 steps. */
  bigStep?: number;
  /** Digits after the decimal mark. Default from the step. */
  decimals?: number;
  /** Quiet text before the value, e.g. "$" */
  prefix?: string;
  /** Quiet text after the value, e.g. "px", "€" or "kg" */
  unit?: string;
  /** Group thousands (1,280). Default true. */
  grouping?: boolean;
  /** scrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box. */
  handle?: string;
  /** scrub: an icon at the start that you drag, instead of the letter */
  handleIcon?: IconName;
  /** quantity: at the minimum the minus turns into a remove button */
  onRemove?: () => void;
  size?: ControlSize;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Accessible name when there is no visible label */
  'aria-label'?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const decimalsOf = (n: number) => {
  const s = String(n);
  return s.includes('.') ? s.split('.')[1].length : 0;
};

/** Reads typed text in the format of the language. Returns null when it is not a number. */
export function parseNumber(text: string, locale: 'en' | 'de'): number | null {
  let s = text.replace(/[\s  ]/g, '').replace(/[^\d.,+-]/g, '');
  if (!s || s === '-' || s === '+') return null;
  if (locale === 'de') {
    if (s.includes(',')) s = s.replace(/\./g, '').replace(',', '.');
    else if (/\.\d{3}(\.|$)/.test(s)) s = s.replace(/\./g, '');
  } else s = s.replace(/,/g, '');
  const n = Number(s);
  return Number.isFinite(n) ? n : null;
}

/** Repeats an action while a button is held: once at once, then faster and faster */
function useHold(action: () => void) {
  const timer = useRef<number | undefined>(undefined);
  const latest = useRef(action);
  latest.current = action;
  const stop = () => window.clearTimeout(timer.current);
  useEffect(() => stop, []);
  const start = (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    e.preventDefault();
    latest.current();
    let delay = 380;
    const tick = () => {
      latest.current();
      delay = Math.max(40, delay * 0.82);
      timer.current = window.setTimeout(tick, delay);
    };
    timer.current = window.setTimeout(tick, delay);
  };
  return { onPointerDown: start, onPointerUp: stop, onPointerLeave: stop, onPointerCancel: stop, onContextMenu: (e: React.MouseEvent) => e.preventDefault() };
}

/** The value slides in from the side it came from: up when it grows, down when it shrinks */
function nudge(el: HTMLElement | null, dir: number) {
  if (!el || !dir || prefersReducedMotion() || typeof el.animate !== 'function') return;
  el.animate([{ transform: `translateY(${dir > 0 ? 7 : -7}px)`, opacity: 0.25 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 260, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}

/** A short sideways bump when a limit is reached */
function bump(el: HTMLElement | null) {
  if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
  el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-2px)' }, { transform: 'translateX(0)' }], { duration: 240, easing: 'ease-out' });
}

const STEP_BTN =
  'relative grid shrink-0 place-items-center text-muted-foreground outline-none transition-[background-color,color,scale] duration-200 ease-[var(--ease-kit)] hover:bg-accent hover:text-foreground active:scale-90 focus-visible:z-10 focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-35 motion-reduce:transition-none';

function NumberBox(props: Omit<NumberInputProps, keyof FieldOwnProps> & { inlineLabel?: React.ReactNode }) {
  const {
    variant = 'stepper', value: valueProp, defaultValue, onValueChange, min = -Infinity, max = Infinity, step = 1, bigStep, decimals, prefix, unit,
    grouping = true, handle, handleIcon, onRemove, size = 'md', name, id, disabled, required, invalid, labels, className, inlineLabel,
  } = props;
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const c = useControl({ id, invalid, disabled, required });
  const digits = decimals ?? decimalsOf(step);
  const big = bigStep ?? step * 10;
  const [inner, setInner] = useState(() => defaultValue ?? (Number.isFinite(min) ? min : 0));
  const value = valueProp ?? inner;
  const [draft, setDraft] = useState<string | null>(null);
  const boxRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const valueRef = useRef<HTMLSpanElement>(null);
  const hintId = useId();
  useShake(boxRef, c.invalid);

  const fmt = (n: number, group = grouping) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits, useGrouping: group });
  const snap = (n: number) => {
    const base = Number.isFinite(min) ? min : 0;
    const snapped = round(base + Math.round((n - base) / step) * step, digits);
    return Math.min(max, Math.max(min, snapped));
  };

  const commit = (next: number, from: 'step' | 'type' = 'step') => {
    // Steps land on the grid of the step, typed values are kept as typed (only limits and digits apply)
    const v = from === 'type' ? Math.min(max, Math.max(min, round(next, digits))) : snap(next);
    if (v === value) {
      if (from === 'step' && next !== value) bump(boxRef.current);
      return;
    }
    if (from === 'step') nudge(variant === 'stepper' || variant === 'scrub' ? inputRef.current : valueRef.current ?? inputRef.current, v - value);
    setInner(v);
    onValueChange?.(v);
  };
  const by = (delta: number) => commit(value + delta);

  const plus = useHold(() => by(step));
  const minus = useHold(() => (onRemove && value <= min ? onRemove() : by(-step)));
  const atMin = value <= min;
  const atMax = value >= max;
  const removeMode = variant === 'quantity' && Boolean(onRemove) && atMin;

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    const k = e.key;
    const map: Record<string, () => void> = {
      ArrowUp: () => by(e.shiftKey ? big : step),
      ArrowDown: () => by(-(e.shiftKey ? big : step)),
      PageUp: () => by(big),
      PageDown: () => by(-big),
      Home: () => Number.isFinite(min) && commit(min),
      End: () => Number.isFinite(max) && commit(max),
    };
    if (map[k]) {
      e.preventDefault();
      if (draft !== null) {
        const typed = parseNumber(draft, locale);
        setDraft(null);
        if (typed !== null && snap(typed) !== value) return commit(typed, 'type');
      }
      map[k]();
    } else if (k === 'Enter') {
      finish();
    } else if (k === 'Escape' && draft !== null) {
      e.preventDefault();
      setDraft(null);
    }
  };
  const finish = () => {
    if (draft === null) return;
    const typed = parseNumber(draft, locale);
    setDraft(null);
    if (typed !== null) commit(typed, 'type');
  };

  /* ---------- scrub: drag the handle sideways ---------- */
  const drag = useRef<{ x: number; start: number; moved: boolean } | null>(null);
  const [dragging, setDragging] = useState(false);
  const scrub = {
    onPointerDown: (e: React.PointerEvent<HTMLSpanElement>) => {
      if (c.disabled || e.button !== 0) return;
      e.preventDefault();
      e.currentTarget.setPointerCapture(e.pointerId);
      drag.current = { x: e.clientX, start: value, moved: false };
      setDragging(true);
    },
    onPointerMove: (e: React.PointerEvent<HTMLSpanElement>) => {
      const d = drag.current;
      if (!d) return;
      const dx = e.clientX - d.x;
      if (Math.abs(dx) > 2) d.moved = true;
      // One step every 4 px, Shift ten times faster, Alt ten times finer
      const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
      const next = snap(d.start + Math.round(dx / 4) * step * factor);
      if (next !== value) {
        setInner(next);
        onValueChange?.(next);
      }
    },
    onPointerUp: () => {
      const d = drag.current;
      drag.current = null;
      setDragging(false);
      if (d && !d.moved) inputRef.current?.focus();
    },
  };
  useEffect(() => {
    if (!dragging) return;
    const prev = document.body.style.cursor;
    document.body.style.cursor = 'ew-resize';
    return () => void (document.body.style.cursor = prev);
  }, [dragging]);

  const shown = draft ?? fmt(value);
  // "50%" in English, "50 %" in German, degrees always close
  const tight = unit === '°' || (unit === '%' && locale === 'en');
  const valueText = `${prefix ?? ''}${fmt(value)}${unit ? `${tight ? '' : ' '}${unit}` : ''}`;
  const input = (
    <input
      ref={inputRef}
      id={c.id}
      type="text"
      inputMode={digits > 0 || min < 0 ? 'decimal' : 'numeric'}
      role="spinbutton"
      aria-valuenow={value}
      aria-valuemin={Number.isFinite(min) ? min : undefined}
      aria-valuemax={Number.isFinite(max) ? max : undefined}
      aria-valuetext={valueText}
      aria-label={props['aria-label']}
      aria-invalid={c['aria-invalid']}
      aria-describedby={cn(c['aria-describedby'], variant === 'scrub' && hintId) || undefined}
      disabled={c.disabled}
      required={c.required}
      autoComplete="off"
      spellCheck={false}
      value={shown}
      onChange={(e) => setDraft(e.target.value)}
      onFocus={(e) => {
        setDraft(fmt(value, false));
        const el = e.currentTarget;
        requestAnimationFrame(() => el.select());
      }}
      onBlur={finish}
      onKeyDown={onKeyDown}
      // In the centered looks the field is as wide as its text, so a unit sits right next to the number
      style={variant === 'split' || variant === 'quantity' ? { width: `${Math.max(shown.length, 1) + 0.6}ch` } : undefined}
      className={cn(
        'h-full min-w-0 bg-transparent tabular-nums text-inherit outline-none placeholder:text-subtle-foreground',
        // Between two buttons the box ring alone does not say which part has focus, so the number gets its own line
        variant === 'split' || variant === 'quantity' ? 'max-w-full flex-none rounded-none text-center font-medium focus-visible:shadow-[inset_0_-2px_0_var(--primary)]' : 'w-0 flex-1 text-start',
      )}
    />
  );
  const hidden = name ? <input type="hidden" name={name} value={value} /> : null;
  const affix = (text: string | undefined, side: 'start' | 'end') =>
    text ? <span aria-hidden className={cn('shrink-0 select-none text-subtle-foreground', side === 'start' ? 'pe-1' : tight ? 'ps-px' : 'ps-1')}>{text}</span> : null;

  /* ---------- split: minus | value | plus ---------- */
  if (variant === 'split' || variant === 'quantity') {
    const q = variant === 'quantity';
    const btn = q ? 'size-11 sm:size-9 rounded-[7px]' : cn('h-full aspect-square', size === 'lg' ? 'w-12' : 'w-11');
    return (
      <div
        ref={boxRef}
        data-invalid={c.invalid || undefined}
        data-disabled={c.disabled || undefined}
        className={cn(
          'group/num relative flex items-center',
          q ? 'h-11 w-[8.5rem] shrink-0 gap-0.5 rounded-lg p-0 text-base sm:h-9 sm:w-[7.25rem] sm:text-sm' : cn('w-full', CONTROL_HEIGHT[size]),
          controlBox,
          q && 'overflow-hidden',
          className,
        )}
      >
        <button
          type="button"
          aria-label={removeMode ? t.remove : t.decrease}
          aria-controls={c.id}
          disabled={c.disabled || (atMin && !removeMode)}
          {...minus}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              if (removeMode) onRemove?.();
              else by(-step);
            }
          }}
          className={cn(STEP_BTN, btn, !q && 'rounded-s-[inherit] border-e border-border', removeMode && 'hover:text-destructive')}
        >
          {/* The minus turns into a bin at the minimum, with a short turn */}
          <span key={removeMode ? 'rm' : 'minus'} className="grid place-items-center transition-[rotate,scale,opacity] duration-300 ease-[var(--ease-kit)] starting:-rotate-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none">
            <Icon name={removeMode ? 'trash' : 'minus'} size={q ? 15 : 17} />
          </span>
        </button>
        <span ref={valueRef} onClick={() => inputRef.current?.focus()} className="flex h-full min-w-0 flex-1 cursor-text items-center justify-center px-1">
          {affix(prefix, 'start')}
          {input}
          {affix(unit, 'end')}
        </span>
        <button
          type="button"
          aria-label={t.increase}
          aria-controls={c.id}
          disabled={c.disabled || atMax}
          {...plus}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              by(step);
            }
          }}
          className={cn(STEP_BTN, btn, !q && 'rounded-e-[inherit] border-s border-border')}
        >
          <Icon name="plus" size={q ? 15 : 17} />
        </button>
        {hidden}
      </div>
    );
  }

  /* ---------- scrub: a design tool field ---------- */
  if (variant === 'scrub') {
    const ranged = Number.isFinite(min) && Number.isFinite(max);
    const p = ranged ? (value - min) / (max - min) : 0;
    return (
      <div
        ref={boxRef}
        data-invalid={c.invalid || undefined}
        data-disabled={c.disabled || undefined}
        data-dragging={dragging || undefined}
        className={cn('group/num relative flex h-11 w-full items-center overflow-hidden rounded-lg text-base sm:h-9 sm:rounded-md sm:text-[13px]', controlBox, dragging && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]', className)}
      >
        {inlineLabel ? (
          // A word as the handle: it is the label of the field too
          <label
            htmlFor={c.id}
            title={t.scrub}
            {...scrub}
            className={cn(
              'flex h-full shrink-0 cursor-ew-resize touch-none select-none items-center ps-3 pe-2 text-[13px] text-muted-foreground transition-colors duration-200 hover:text-foreground sm:ps-2.5 sm:text-[12.5px]',
              dragging && 'text-primary',
              c.disabled && 'pointer-events-none',
            )}
          >
            {inlineLabel}
          </label>
        ) : (
          <span
            aria-hidden
            title={t.scrub}
            {...scrub}
            className={cn(
              'grid h-full w-11 shrink-0 cursor-ew-resize touch-none select-none place-items-center text-[12px] font-medium text-subtle-foreground transition-colors duration-200 hover:text-foreground sm:w-8',
              dragging && 'text-primary',
              c.disabled && 'pointer-events-none',
            )}
          >
            {handleIcon ? <Icon name={handleIcon} size={15} /> : handle}
          </span>
        )}
        <span className={cn('flex h-full min-w-0 flex-1 items-center pe-2.5', inlineLabel && '[&>input]:text-end')}>
          {affix(prefix, 'start')}
          {input}
          {affix(unit, 'end')}
        </span>
        {/* Where the value sits in its range, a hairline along the bottom edge */}
        {ranged && (
          <span aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] overflow-hidden">
            <span
              className={cn('block h-full origin-left bg-primary transition-[scale,opacity] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none', dragging ? 'opacity-100' : 'opacity-0 group-hover/num:opacity-60 group-focus-within/num:opacity-60')}
              style={{ scale: `${round(p, 4)} 1` }}
            />
          </span>
        )}
        <span id={hintId} className="sr-only">{t.scrub}</span>
        {hidden}
      </div>
    );
  }

  /* ---------- stepper: buttons stacked at the end ---------- */
  return (
    <div
      ref={boxRef}
      data-invalid={c.invalid || undefined}
      data-disabled={c.disabled || undefined}
      className={cn('group/num relative flex w-full items-stretch overflow-hidden', CONTROL_HEIGHT[size], controlBox, className)}
    >
      <span className={cn('flex min-w-0 flex-1 items-center', size === 'sm' ? 'px-3' : size === 'lg' ? 'px-4' : 'px-3.5')}>
        {affix(prefix, 'start')}
        {input}
        {affix(unit, 'end')}
      </span>
      {/* Phones get two wide buttons side by side, larger screens a slim column */}
      <span className="flex shrink-0 border-s border-border sm:w-8 sm:flex-col">
        <button type="button" tabIndex={-1} aria-label={t.increase} aria-controls={c.id} disabled={c.disabled || atMax} {...plus} className={cn(STEP_BTN, 'order-2 w-11 border-s border-border sm:order-1 sm:w-full sm:flex-1 sm:border-s-0 sm:border-b')}>
          <Icon name="plus" size={15} className="sm:hidden" />
          <Icon name="chevron-up" size={13} strokeWidth={2} className="max-sm:hidden" />
        </button>
        <button type="button" tabIndex={-1} aria-label={t.decrease} aria-controls={c.id} disabled={c.disabled || atMin} {...minus} className={cn(STEP_BTN, 'order-1 w-11 sm:order-2 sm:w-full sm:flex-1')}>
          <Icon name="minus" size={15} className="sm:hidden" />
          <Icon name="chevron-down" size={13} strokeWidth={2} className="max-sm:hidden" />
        </button>
      </span>
      {hidden}
    </div>
  );
}

/**
 * A number field with steps, limits and the number format of the language.
 *   <NumberInput label="Copies" min={1} max={500} defaultValue={50} unit="pcs" />
 *   <NumberInput variant="split" label="Adults" min={1} max={8} defaultValue={2} />
 *   <NumberInput variant="quantity" defaultValue={1} min={1} onRemove={() => remove(id)} aria-label="Quantity" />
 *   <NumberInput variant="scrub" handle="W" unit="px" min={0} max={1440} defaultValue={320} aria-label="Width" />
 */
export function NumberInput({ label, hint, error, optional, labelAction, className, ...rest }: NumberInputProps) {
  // scrub without a letter or icon: the label itself is the handle, inside the box
  if (rest.variant === 'scrub' && label && !rest.handle && !rest.handleIcon) {
    const box = <NumberBox {...rest} inlineLabel={label} invalid={rest.invalid ?? (error ? true : undefined)} className={hint || error ? undefined : className} />;
    return withField({ hint, error, id: rest.id, required: rest.required, disabled: rest.disabled, className }, box);
  }
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <NumberBox {...rest} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />);
}

Häufige Fragen

Wie installiere ich Zahlenfeld?

Mit aiw add @ai-website/number-input. Der Befehl legt die Datei components/ui/number-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Zahlenfeld?

4 Varianten, Stepper, Geteilt, Menge im Warenkorb und Ziehen. Du wählst sie mit der Prop variant, zum Beispiel variant="quantity".

Kann ich die Texte von Zahlenfeld übersetzen?

Ja. Zahlenfeld bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Erhöhen“, „Verringern“ und „Entfernen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Zahlenfeld einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Zahlenfeld mit aiw_components und installieren sie mit aiw_add @ai-website/number-input. Nenn im Prompt die Variante, etwa variant="split", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Zahlenfeld ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden NumberInput-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert