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
Variants
Details
Was Zahlenfeld mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Picker und Auswahlfelder
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/number-input.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 4 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
variantoptionalNumberInputVariantsteppersplitquantityscrub
valueoptionalnumberdefaultValueoptionalnumberonValueChangeoptional(value: number) => voidminoptionalnumbermaxoptionalnumberstepoptionalnumberbigStepoptionalnumberStep for Page Up, Page Down and Shift with an arrow. Default 10 steps.
decimalsoptionalnumberDigits after the decimal mark. Default from the step.
prefixoptionalstringQuiet text before the value, e.g. "$"
unitoptionalstringQuiet text after the value, e.g. "px", "€" or "kg"
groupingoptionalbooleanGroup thousands (1,280). Default true.
handleoptionalstringscrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box.
handleIconoptionalIconNamescrub: an icon at the start that you drag, instead of the letter
onRemoveoptional() => voidquantity: at the minimum the minus turns into a remove button
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleanaria-labeloptionalstringAccessible 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| increase | Englisch Increase | Deutsch Erhöhen |
| decrease | Englisch Decrease | Deutsch Verringern |
| remove | Englisch Remove | Deutsch Entfernen |
| scrub | Englisch Drag sideways to change | Deutsch 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
'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.