Form Builder
Form-Builder-Screens. Felder hinzufügen, ordnen und einstellen, Logik für Folgefragen, Live-Vorschau auf Desktop und Telefon, Antworten mit Übersicht und Tabelle und Veröffentlichen mit Link, Einbettung und QR-Code.
Eine von 3 Components in Automation und Builder
Variants
Details
Was Form Builder mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Automation und Builder
- Bereich
- App
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/form-builder.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 84 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FormBuilderProps aus components/ui/form-builder.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFormBuilderVariantbuilderlogicpreviewresponsespublish
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringformTitleoptionalstringTitle and intro shown on the form itself
formIntrooptionalstringfieldsoptionalFormField[]logicoptionalLogicRule[]responsesoptionalFormResponse[]urloptionalstringpublish: the public address of the form
nowoptionalstringISO date and time that counts as now
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, publish and submit. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt Form Builder selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 84 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| text | Englisch Short text | Deutsch Kurzer Text |
| Englisch Email | Deutsch E-Mail | |
| long | Englisch Long text | Deutsch Langer Text |
| choice | Englisch Single choice | Deutsch Einfachauswahl |
| checkbox | Englisch Multiple choice | Deutsch Mehrfachauswahl |
| rating | Englisch Rating | Deutsch Bewertung |
| date | Englisch Date | Deutsch Datum |
| number | Englisch Number | Deutsch Zahl |
| fields | Englisch Fields | Deutsch Felder |
| addField | Englisch Add field | Deutsch Feld hinzufügen |
| addType | Englisch Add {type} | Deutsch {type} hinzufügen |
| newField | Englisch New question | Deutsch Neue Frage |
| moveUp | Englisch Move {name} up | Deutsch {name} nach oben |
| moveDown | Englisch Move {name} down | Deutsch {name} nach unten |
| duplicate | Englisch Duplicate {name} | Deutsch {name} duplizieren |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| settings | Englisch Field settings | Deutsch Feldeinstellungen |
| pickField | Englisch Pick a field to change it. | Deutsch Wähle ein Feld, um es zu ändern. |
| label | Englisch Question | Deutsch Frage |
| help | Englisch Help text | Deutsch Hilfetext |
| placeholder | Englisch Placeholder | Deutsch Platzhalter |
| required | Englisch Required | Deutsch Pflichtfeld |
| requiredText | Englisch The form cannot be sent without an answer. | Deutsch Ohne Antwort lässt sich das Formular nicht senden. |
| options | Englisch Options | Deutsch Optionen |
| addOption | Englisch Add option | Deutsch Option hinzufügen |
| option | Englisch Option {n} | Deutsch Option {n} |
| removeOption | Englisch Remove option {n} | Deutsch Option {n} entfernen |
| form | Englisch Form | Deutsch Formular |
| logic | Englisch Logic | Deutsch Logik |
| logicText | Englisch Show questions only when an earlier answer fits. Hidden questions are never required. | Deutsch Fragen nur zeigen, wenn eine frühere Antwort passt. Versteckte Fragen sind nie Pflicht. |
| when | Englisch When | Deutsch Wenn |
| is | Englisch is | Deutsch ist |
| show | Englisch show | Deutsch zeige |
| addRule | Englisch Add rule | Deutsch Regel hinzufügen |
| removeRule | Englisch Remove rule | Deutsch Regel entfernen |
| noLogic | Englisch No rules yet. Every question is shown to everyone. | Deutsch Noch keine Regeln. Alle sehen jede Frage. |
| pathFor | Englisch What people see | Deutsch Was Leute sehen |
| pathHint | Englisch Pick an answer to see which questions follow. | Deutsch Wähle eine Antwort, um zu sehen, welche Fragen folgen. |
| alwaysShown | Englisch Always | Deutsch Immer |
| shownIf | Englisch If {field} is {value} | Deutsch Wenn {field} {value} ist |
| desktop | Englisch Desktop | Deutsch Desktop |
| phone | Englisch Phone | Deutsch Telefon |
| device | Englisch Device | Deutsch Gerät |
| submit | Englisch Send | Deutsch Senden |
| sending | Englisch Sending… | Deutsch Wird gesendet… |
| thanks | Englisch Thank you, your answer is in. | Deutsch Danke, deine Antwort ist da. |
| thanksText | Englisch We read every answer and get back within two working days. | Deutsch Wir lesen jede Antwort und melden uns innerhalb von zwei Werktagen. |
| again | Englisch Fill in again | Deutsch Erneut ausfüllen |
| requiredError | Englisch Please answer this question. | Deutsch Bitte beantworte diese Frage. |
| emailError | Englisch Enter an email address like name@example.com. | Deutsch Gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| responses | Englisch Responses | Deutsch Antworten |
| answersN | Englisch {n} answers | Deutsch {n} Antworten |
| total | Englisch Responses | Deutsch Antworten |
| today | Englisch Today | Deutsch Heute |
| completion | Englisch Finished | Deutsch Abgeschlossen |
| avgRating | Englisch Average rating | Deutsch Durchschnitt |
| summary | Englisch Summary | Deutsch Übersicht |
| table | Englisch All answers | Deutsch Alle Antworten |
| download | Englisch Download CSV | Deutsch CSV herunterladen |
| search | Englisch Search answers | Deutsch Antworten suchen |
| noAnswer | Englisch · | Deutsch · |
| publish | Englisch Publish | Deutsch Veröffentlichen |
| published | Englisch Live | Deutsch Live |
| draft | Englisch Draft | Deutsch Entwurf |
| link | Englisch Link | Deutsch Link |
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| embed | Englisch Embed | Deutsch Einbetten |
| embedText | Englisch Paste this code where the form should appear. | Deutsch Füge diesen Code dort ein, wo das Formular erscheinen soll. |
| qr | Englisch QR code | Deutsch QR-Code |
| qrText | Englisch For posters, menus and counters. | Deutsch Für Aushänge, Speisekarten und Theken. |
| accept | Englisch Accept responses | Deutsch Antworten annehmen |
| acceptText | Englisch Turn off to close the form, the link shows a short note. | Deutsch Aus schließt das Formular, der Link zeigt einen kurzen Hinweis. |
| closeOn | Englisch Close on a date | Deutsch An einem Datum schließen |
| limit | Englisch Limit responses | Deutsch Antworten begrenzen |
| limitN | Englisch Up to {n} responses | Deutsch Bis zu {n} Antworten |
| notify | Englisch Email me for every response | Deutsch Bei jeder Antwort per E-Mail benachrichtigen |
| goLive | Englisch Publish form | Deutsch Formular veröffentlichen |
| going | Englisch Publishing… | Deutsch Wird veröffentlicht… |
| live | Englisch The form is live. Share the link to start collecting answers. | Deutsch Das Formular ist live. Teile den Link, um Antworten zu sammeln. |
| options_ | Englisch Settings | Deutsch Einstellungen |
| saveLogic | Englisch Save logic | Deutsch Logik speichern |
| logicSaved | Englisch Logic saved | Deutsch Logik gespeichert |
| time | Englisch Time | Deutsch Zeit |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Form Builder reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-current, aria-describedby, aria-invalid, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, group, radio, radiogroup und status.
- 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
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.285 Zeilen
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';
export type FormBuilderVariant = 'builder' | 'logic' | 'preview' | 'responses' | 'publish';
export type FieldType = 'text' | 'email' | 'long' | 'choice' | 'checkbox' | 'rating' | 'date' | 'number';
export interface FormField {
id: string;
type: FieldType;
label: string;
help?: string;
required?: boolean;
placeholder?: string;
/** choice, checkbox */
options?: string[];
}
export interface LogicRule {
id: string;
/** Field id that decides */
when: string;
/** Answer that triggers it */
equals: string;
/** Field ids that show up */
show: string[];
}
export interface FormResponse {
id: string;
/** ISO date and time */
time: string;
/** Answer per field id */
answers: Record<string, string>;
}
export interface FormBuilderProps {
variant?: FormBuilderVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** Title and intro shown on the form itself */
formTitle?: string;
formIntro?: string;
fields?: FormField[];
logic?: LogicRule[];
responses?: FormResponse[];
/** publish: the public address of the form */
url?: string;
/** ISO date and time that counts as now */
now?: string;
/** Called on save, publish and submit. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
text: 'Short text',
email: 'Email',
long: 'Long text',
choice: 'Single choice',
checkbox: 'Multiple choice',
rating: 'Rating',
date: 'Date',
number: 'Number',
fields: 'Fields',
addField: 'Add field',
addType: 'Add {type}',
newField: 'New question',
moveUp: 'Move {name} up',
moveDown: 'Move {name} down',
duplicate: 'Duplicate {name}',
remove: 'Remove {name}',
settings: 'Field settings',
pickField: 'Pick a field to change it.',
label: 'Question',
help: 'Help text',
placeholder: 'Placeholder',
required: 'Required',
requiredText: 'The form cannot be sent without an answer.',
options: 'Options',
addOption: 'Add option',
option: 'Option {n}',
removeOption: 'Remove option {n}',
form: 'Form',
logic: 'Logic',
logicText: 'Show questions only when an earlier answer fits. Hidden questions are never required.',
when: 'When',
is: 'is',
show: 'show',
addRule: 'Add rule',
removeRule: 'Remove rule',
noLogic: 'No rules yet. Every question is shown to everyone.',
pathFor: 'What people see',
pathHint: 'Pick an answer to see which questions follow.',
alwaysShown: 'Always',
shownIf: 'If {field} is {value}',
desktop: 'Desktop',
phone: 'Phone',
device: 'Device',
submit: 'Send',
sending: 'Sending…',
thanks: 'Thank you, your answer is in.',
thanksText: 'We read every answer and get back within two working days.',
again: 'Fill in again',
requiredError: 'Please answer this question.',
emailError: 'Enter an email address like name@example.com.',
responses: 'Responses',
answersN: '{n} answers',
total: 'Responses',
today: 'Today',
completion: 'Finished',
avgRating: 'Average rating',
summary: 'Summary',
table: 'All answers',
download: 'Download CSV',
search: 'Search answers',
noAnswer: '·',
publish: 'Publish',
published: 'Live',
draft: 'Draft',
link: 'Link',
copy: 'Copy',
copied: 'Copied',
embed: 'Embed',
embedText: 'Paste this code where the form should appear.',
qr: 'QR code',
qrText: 'For posters, menus and counters.',
accept: 'Accept responses',
acceptText: 'Turn off to close the form, the link shows a short note.',
closeOn: 'Close on a date',
limit: 'Limit responses',
limitN: 'Up to {n} responses',
notify: 'Email me for every response',
goLive: 'Publish form',
going: 'Publishing…',
live: 'The form is live. Share the link to start collecting answers.',
options_: 'Settings',
saveLogic: 'Save logic',
logicSaved: 'Logic saved',
time: 'Time',
},
de: {
text: 'Kurzer Text',
email: 'E-Mail',
long: 'Langer Text',
choice: 'Einfachauswahl',
checkbox: 'Mehrfachauswahl',
rating: 'Bewertung',
date: 'Datum',
number: 'Zahl',
fields: 'Felder',
addField: 'Feld hinzufügen',
addType: '{type} hinzufügen',
newField: 'Neue Frage',
moveUp: '{name} nach oben',
moveDown: '{name} nach unten',
duplicate: '{name} duplizieren',
remove: '{name} entfernen',
settings: 'Feldeinstellungen',
pickField: 'Wähle ein Feld, um es zu ändern.',
label: 'Frage',
help: 'Hilfetext',
placeholder: 'Platzhalter',
required: 'Pflichtfeld',
requiredText: 'Ohne Antwort lässt sich das Formular nicht senden.',
options: 'Optionen',
addOption: 'Option hinzufügen',
option: 'Option {n}',
removeOption: 'Option {n} entfernen',
form: 'Formular',
logic: 'Logik',
logicText: 'Fragen nur zeigen, wenn eine frühere Antwort passt. Versteckte Fragen sind nie Pflicht.',
when: 'Wenn',
is: 'ist',
show: 'zeige',
addRule: 'Regel hinzufügen',
removeRule: 'Regel entfernen',
noLogic: 'Noch keine Regeln. Alle sehen jede Frage.',
pathFor: 'Was Leute sehen',
pathHint: 'Wähle eine Antwort, um zu sehen, welche Fragen folgen.',
alwaysShown: 'Immer',
shownIf: 'Wenn {field} {value} ist',
desktop: 'Desktop',
phone: 'Telefon',
device: 'Gerät',
submit: 'Senden',
sending: 'Wird gesendet…',
thanks: 'Danke, deine Antwort ist da.',
thanksText: 'Wir lesen jede Antwort und melden uns innerhalb von zwei Werktagen.',
again: 'Erneut ausfüllen',
requiredError: 'Bitte beantworte diese Frage.',
emailError: 'Gib eine E-Mail-Adresse wie name@beispiel.de ein.',
responses: 'Antworten',
answersN: '{n} Antworten',
total: 'Antworten',
today: 'Heute',
completion: 'Abgeschlossen',
avgRating: 'Durchschnitt',
summary: 'Übersicht',
table: 'Alle Antworten',
download: 'CSV herunterladen',
search: 'Antworten suchen',
noAnswer: '·',
publish: 'Veröffentlichen',
published: 'Live',
draft: 'Entwurf',
link: 'Link',
copy: 'Kopieren',
copied: 'Kopiert',
embed: 'Einbetten',
embedText: 'Füge diesen Code dort ein, wo das Formular erscheinen soll.',
qr: 'QR-Code',
qrText: 'Für Aushänge, Speisekarten und Theken.',
accept: 'Antworten annehmen',
acceptText: 'Aus schließt das Formular, der Link zeigt einen kurzen Hinweis.',
closeOn: 'An einem Datum schließen',
limit: 'Antworten begrenzen',
limitN: 'Bis zu {n} Antworten',
notify: 'Bei jeder Antwort per E-Mail benachrichtigen',
goLive: 'Formular veröffentlichen',
going: 'Wird veröffentlicht…',
live: 'Das Formular ist live. Teile den Link, um Antworten zu sammeln.',
options_: 'Einstellungen',
saveLogic: 'Logik speichern',
logicSaved: 'Logik gespeichert',
time: 'Zeit',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
type P = Omit<FormBuilderProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; fields: FormField[]; now: string };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: FormBuilderProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
const TYPE_ICON: Record<FieldType, IconName> = { text: 'type', email: 'at-sign', long: 'align-left', choice: 'circle', checkbox: 'check-square', rating: 'star', date: 'calendar', number: 'hash' };
const TYPES: FieldType[] = ['text', 'email', 'long', 'choice', 'checkbox', 'rating', 'date', 'number'];
const control = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[14px]';
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
return (
<section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
/** One field as the person filling in sees it. `value` and `onChange` make it live, without them it is a still preview. */
function FieldView({ f, t, value = '', onChange, error, idBase }: { f: FormField; t: Texts; value?: string; onChange?: (v: string) => void; error?: string; idBase: string }) {
const id = `${idBase}-${f.id}`;
const still = !onChange;
const label = (
<span className="flex items-baseline gap-1 text-[14px] font-medium text-foreground">
{f.label}
{f.required && <span aria-hidden className="text-destructive">*</span>}
</span>
);
const help = f.help && <span className="mt-0.5 block text-[12.5px] text-muted-foreground">{f.help}</span>;
const err = error && (
<span role="alert" className="kitm-in mt-1.5 flex items-center gap-1.5 text-[12.5px] font-medium text-destructive">
<Icon name="alert-circle" size={14} />
{error}
</span>
);
const ring = error ? 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]' : '';
if (f.type === 'choice' || f.type === 'checkbox') {
const picked = value ? value.split('|') : [];
return (
<fieldset aria-describedby={error ? `${id}-e` : undefined}>
<legend>{label}</legend>
{help}
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{(f.options ?? []).map((o) => {
const on = picked.includes(o);
return (
<label key={o} className={cn('relative flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-[14px] transition-[border-color,background-color] duration-200', still ? 'cursor-default' : 'cursor-pointer', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input type={f.type === 'choice' ? 'radio' : 'checkbox'} name={id} disabled={still} checked={on} onChange={() => onChange?.(f.type === 'choice' ? o : (on ? picked.filter((x) => x !== o) : [...picked, o]).join('|'))} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', f.type === 'choice' ? 'rounded-full' : 'rounded-[5px]', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{on && (f.type === 'choice' ? <span className="size-[7px] rounded-full bg-primary-foreground" /> : <Icon name="check" size={12} strokeWidth={2.6} />)}
</span>
<span className="text-foreground">{o}</span>
</label>
);
})}
</div>
{err}
</fieldset>
);
}
if (f.type === 'rating') {
const n = Number(value) || 0;
return (
<fieldset>
<legend>{label}</legend>
{help}
<div className="mt-2 flex gap-1.5" role="radiogroup" aria-label={f.label}>
{[1, 2, 3, 4, 5].map((k) => (
<button key={k} type="button" role="radio" aria-checked={n === k} aria-label={`${k} / 5`} disabled={still} onClick={() => onChange?.(String(k))} className={cn('grid size-11 place-items-center rounded-lg border transition-[transform,background-color,border-color] duration-200 active:scale-90', k <= n ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] text-warning-foreground' : 'border-border text-subtle-foreground hover:text-foreground', still && 'cursor-default')}>
<Icon name="star" size={18} className={k <= n ? '[&_path]:fill-current' : ''} />
</button>
))}
</div>
{err}
</fieldset>
);
}
const Tag = f.type === 'long' ? 'textarea' : 'input';
return (
<label className="block" htmlFor={id}>
{label}
{help}
<Tag
id={id}
readOnly={still}
tabIndex={still ? -1 : undefined}
type={f.type === 'email' ? 'email' : f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
rows={f.type === 'long' ? 4 : undefined}
placeholder={f.placeholder}
value={value}
aria-invalid={Boolean(error)}
onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange?.(e.target.value)}
className={cn(control, 'mt-2', f.type === 'long' && 'h-auto py-2.5 sm:h-auto', ring, still && 'pointer-events-none')}
/>
{err}
</label>
);
}
/* ------------------------------------------------------------------ */
/* builder */
/* ------------------------------------------------------------------ */
function Builder({ t, title, subtitle, fields: start, formTitle, formIntro }: P) {
const id = useId();
const [fields, setFields] = useState(start);
const [sel, setSel] = useState(start[0]?.id ?? '');
const [fresh, setFresh] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const listRef = useRef<HTMLOListElement>(null);
const [rowRef, fade] = useScrollFade<HTMLUListElement>();
const f = fields.find((x) => x.id === sel);
const set = (patch: Partial<FormField>) => setFields((l) => l.map((x) => (x.id === sel ? { ...x, ...patch } : x)));
const add = (type: FieldType) => {
const n: FormField = { id: `f${Date.now()}`, type, label: t.newField, options: type === 'choice' || type === 'checkbox' ? [fill(t.option, { n: 1 }), fill(t.option, { n: 2 })] : undefined };
const at = Math.max(0, fields.findIndex((x) => x.id === sel)) + 1;
setFields((l) => [...l.slice(0, at), n, ...l.slice(at)]);
setSel(n.id);
setFresh(n.id);
setMsg(fill(t.addType, { type: t[type] }));
};
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= fields.length) return;
setFields((l) => {
const c = [...l];
[c[i], c[j]] = [c[j], c[i]];
return c;
});
};
// Fields glide to their new place when moved
const before = useRef<Map<string, number>>(new Map());
if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
useIso(() => {
if (!listRef.current || prefersReducedMotion()) return;
Array.from(listRef.current.children).forEach((el) => {
const a = before.current.get((el as HTMLElement).dataset.id ?? '');
const b = el.getBoundingClientRect().top;
if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 420, easing: KITM_EASE });
});
}, [fields.map((x) => x.id).join()]);
const settings = f && (
<div key={f.id} className="kitm-in grid gap-4">
<p className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
<Icon name={TYPE_ICON[f.type]} size={14} />
{t[f.type]}
</p>
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-l`}>
{t.label}
<input id={`${id}-l`} value={f.label} onChange={(e) => set({ label: e.target.value })} className={control} />
</label>
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-h`}>
{t.help}
<input id={`${id}-h`} value={f.help ?? ''} onChange={(e) => set({ help: e.target.value })} className={control} />
</label>
{(f.type === 'text' || f.type === 'email' || f.type === 'long' || f.type === 'number') && (
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-p`}>
{t.placeholder}
<input id={`${id}-p`} value={f.placeholder ?? ''} onChange={(e) => set({ placeholder: e.target.value })} className={control} />
</label>
)}
{f.options && (
<div>
<p className="text-[13px] font-medium text-foreground">{t.options}</p>
<ul className="mt-1.5 grid gap-1.5">
{f.options.map((o, i) => (
<li key={i} className="kitm-in flex items-center gap-2">
<span aria-hidden className={cn('size-3.5 shrink-0 border border-border-strong', f.type === 'choice' ? 'rounded-full' : 'rounded-[4px]')} />
<input aria-label={fill(t.option, { n: i + 1 })} value={o} onChange={(e) => set({ options: f.options!.map((x, k) => (k === i ? e.target.value : x)) })} className={cn(control, 'sm:h-9')} />
<button type="button" aria-label={fill(t.removeOption, { n: i + 1 })} disabled={f.options!.length < 3} onClick={() => set({ options: f.options!.filter((_, k) => k !== i) })} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive disabled:opacity-30 sm:size-9">
<Icon name="x" size={15} />
</button>
</li>
))}
</ul>
<Button variant="ghost" size="sm" iconLeft="plus" className="mt-1" onClick={() => set({ options: [...f.options!, fill(t.option, { n: f.options!.length + 1 })] })}>
{t.addOption}
</Button>
</div>
)}
<Switch checked={Boolean(f.required)} onCheckedChange={(v) => set({ required: v })} label={t.required} description={t.requiredText} className="border-t border-border pt-4" />
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[minmax(0,1fr)_17rem] xl:grid-cols-[13rem_minmax(0,1fr)_19rem] lg:items-start">
{/* Wide screens: the field types as a column. Below 1280 px, where the app sidebar takes room, a row of chips above the form. */}
<nav aria-label={t.addField} className="kitm-up md:col-span-2 xl:col-span-1 xl:sticky xl:top-6">
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.addField}</p>
<ul ref={rowRef} className={cn('kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 xl:mx-0 xl:grid xl:gap-1 xl:overflow-visible xl:px-0 xl:[mask-image:none]', fade)}>
{TYPES.map((ty) => (
<li key={ty} className="shrink-0">
<button type="button" aria-label={fill(t.addType, { type: t[ty] })} onClick={() => add(ty)} className="group flex h-11 w-full items-center gap-2.5 whitespace-nowrap rounded-lg border border-border bg-card px-3 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-[border-color,transform] duration-200 hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] active:scale-[0.98] xl:h-10">
<Icon name={TYPE_ICON[ty]} size={15} className="shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
{t[ty]}
<Icon name="plus" size={14} className="ms-auto hidden shrink-0 text-subtle-foreground xl:block" />
</button>
</li>
))}
</ul>
</nav>
{/* The form as it will look, each field a block that can be picked */}
<section aria-label={t.form} className="kitm-up [--kitm-d:80ms] min-w-0 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<header className="border-b border-border px-5 py-5 sm:px-7">
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-foreground">{formTitle}</h2>
{formIntro && <p className="mt-1 text-pretty text-[14px] text-muted-foreground">{formIntro}</p>}
</header>
<ol ref={listRef} className="grid gap-2 p-3 sm:p-4">
{fields.map((x, i) => (
<li key={x.id} data-id={x.id} className={cn(x.id === fresh && 'kitm-pop')}>
<div
role="group"
tabIndex={0}
aria-label={`${t[x.type]}: ${x.label}`}
aria-current={x.id === sel ? 'true' : undefined}
onClick={() => setSel(x.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSel(x.id);
} else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
move(i, e.key === 'ArrowUp' ? -1 : 1);
}
}}
className={cn('group relative rounded-lg border p-4 outline-none transition-[border-color,background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:p-5', x.id === sel ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'cursor-pointer border-transparent hover:border-border hover:bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)]')}
>
{x.id === sel && <span aria-hidden className="absolute inset-y-3 start-0 w-0.5 rounded-full bg-primary" />}
<FieldView f={x} t={t} idBase={`${id}-b`} />
<div className={cn('kitm-in mt-3 items-center justify-end gap-0.5 [--kitm-t:.3s]', x.id === sel ? 'flex' : 'hidden max-md:flex')}>
{[
{ icon: 'arrow-up' as IconName, label: fill(t.moveUp, { name: x.label }), on: () => move(i, -1), off: i === 0 },
{ icon: 'arrow-down' as IconName, label: fill(t.moveDown, { name: x.label }), on: () => move(i, 1), off: i === fields.length - 1 },
{
icon: 'copy' as IconName,
label: fill(t.duplicate, { name: x.label }),
on: () => {
const c = { ...x, id: `f${Date.now()}` };
setFields((l) => [...l.slice(0, i + 1), c, ...l.slice(i + 1)]);
setSel(c.id);
setFresh(c.id);
},
},
{
icon: 'trash' as IconName,
label: fill(t.remove, { name: x.label }),
on: () => {
setFields((l) => l.filter((y) => y.id !== x.id));
setSel(fields[i + 1]?.id ?? fields[i - 1]?.id ?? '');
},
},
].map((b) => (
<button
key={b.icon}
type="button"
aria-label={b.label}
disabled={b.off}
onClick={(e) => {
e.stopPropagation();
b.on();
}}
className={cn('grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8', b.icon === 'trash' && 'hover:text-destructive')}
>
<Icon name={b.icon} size={15} />
</button>
))}
</div>
{/* Phones: the settings open right under the picked field */}
{x.id === sel && <div className="mt-4 border-t border-border pt-4 md:hidden" onClick={(e) => e.stopPropagation()}>{settings}</div>}
</div>
</li>
))}
</ol>
</section>
<aside aria-label={t.settings} className="kitm-up [--kitm-d:160ms] hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] md:block md:sticky md:top-6">
<h2 className="border-b border-border px-4 py-3.5 text-[14px] font-semibold text-foreground sm:px-5">{t.settings}</h2>
<div className="p-4 sm:p-5">{settings ?? <p className="text-[13px] text-muted-foreground">{t.pickField}</p>}</div>
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* logic */
/* ------------------------------------------------------------------ */
function Logic({ t, title, subtitle, fields, logic = [], onAction }: P) {
const [rules, setRules] = useState(logic);
const [probe, setProbe] = useState<Record<string, string>>({});
const [saved, setSaved] = useState(false);
const [scope, enter] = useEnter<HTMLDivElement>();
const deciders = fields.filter((f) => f.type === 'choice' || f.type === 'rating');
const label = (fid: string) => fields.find((f) => f.id === fid)?.label ?? fid;
const ruleFor = (fid: string) => rules.find((r) => r.show.includes(fid));
const visible = (fid: string) => {
const r = ruleFor(fid);
return !r || probe[r.when] === r.equals;
};
const set = (rid: string, patch: Partial<LogicRule>) => {
setRules((l) => l.map((r) => (r.id === rid ? { ...r, ...patch } : r)));
setSaved(false);
};
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
<Card title={t.logic} description={t.logicText} className="kitm-up" bodyClassName="p-0 sm:p-0">
<ul className="divide-y divide-border">
{rules.map((r, i) => {
const decider = fields.find((f) => f.id === r.when);
const after = fields.slice(fields.findIndex((f) => f.id === r.when) + 1);
return (
<li key={r.id} className="kitm-up grid gap-3 px-4 py-4 sm:px-5" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<div className="flex flex-wrap items-center gap-2 text-[13px]">
<span className="font-semibold text-foreground">{t.when}</span>
<span className="relative">
<select aria-label={t.when} value={r.when} onChange={(e) => set(r.id, { when: e.target.value, equals: fields.find((f) => f.id === e.target.value)?.options?.[0] ?? '' })} className={cn(control, 'w-auto max-w-[14rem] appearance-none pe-8 sm:h-9 sm:text-[13px]')}>
{deciders.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
<span className="text-muted-foreground">{t.is}</span>
<span className="relative">
<select aria-label={t.is} value={r.equals} onChange={(e) => set(r.id, { equals: e.target.value })} className={cn(control, 'w-auto max-w-[14rem] appearance-none pe-8 sm:h-9 sm:text-[13px]')}>
{(decider?.type === 'rating' ? ['1', '2', '3', '4', '5'] : decider?.options ?? []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
<button type="button" aria-label={t.removeRule} onClick={() => setRules((l) => l.filter((x) => x.id !== r.id))} className="ms-auto grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-8">
<Icon name="trash" size={15} />
</button>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="me-1 text-[13px] font-semibold text-foreground">{t.show}</span>
{after.map((f) => {
const on = r.show.includes(f.id);
return (
<button key={f.id} type="button" aria-pressed={on} onClick={() => set(r.id, { show: on ? r.show.filter((x) => x !== f.id) : [...r.show, f.id] })} className={cn('inline-flex h-11 items-center sm:h-9 gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-medium transition-colors', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'border-dashed border-border-strong text-muted-foreground hover:text-foreground')}>
<Icon name={on ? 'check' : 'plus'} size={13} />
{f.label}
</button>
);
})}
</div>
</li>
);
})}
</ul>
{!rules.length && <p className="px-5 py-8 text-center text-[13.5px] text-muted-foreground">{t.noLogic}</p>}
<div className="border-t border-border px-4 py-3 sm:px-5">
<Button variant="ghost" size="sm" iconLeft="plus" disabled={!deciders.length} onClick={() => setRules((l) => [...l, { id: `r${Date.now()}`, when: deciders[0].id, equals: deciders[0].options?.[0] ?? '5', show: [] }])}>
{t.addRule}
</Button>
</div>
</Card>
{/* What a person sees for the answers picked here */}
<Card title={t.pathFor} description={t.pathHint} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
<ol className="relative">
{fields.map((f, i) => {
const r = ruleFor(f.id);
const on = visible(f.id);
const decides = rules.some((x) => x.when === f.id);
return (
<li key={f.id} className={cn('relative border-b border-border px-4 py-3 transition-[opacity,background-color] duration-300 last:border-b-0 sm:px-5', on ? '' : 'bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)] opacity-45')}>
<p className="flex items-center gap-2 text-[13.5px] font-medium text-foreground">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted text-[11px] font-semibold tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate">{f.label}</span>
<Icon name={on ? 'eye' : 'eye-off'} size={14} className="shrink-0 text-subtle-foreground" />
</p>
<p className="mt-0.5 ps-8 text-[12px] text-muted-foreground">{r ? fill(t.shownIf, { field: label(r.when), value: r.equals }) : t.alwaysShown}</p>
{decides && (
<div className="mt-2 flex flex-wrap gap-1.5 ps-8">
{(f.type === 'rating' ? ['1', '2', '3', '4', '5'] : f.options ?? []).map((o) => (
<button key={o} type="button" aria-pressed={probe[f.id] === o} onClick={() => setProbe((p) => ({ ...p, [f.id]: p[f.id] === o ? '' : o }))} className={cn('h-11 min-w-11 rounded-md border px-2.5 text-[12px] sm:h-8 sm:min-w-8 sm:px-2 font-medium transition-colors', probe[f.id] === o ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
{o}
</button>
))}
</div>
)}
</li>
);
})}
</ol>
<div className="border-t border-border p-4 sm:p-5">
<Button
className="w-full"
size="sm"
iconLeft={saved ? 'check' : undefined}
onClick={async () => {
await run(onAction, 'save-logic', { rules });
setSaved(true);
}}
>
{saved ? t.logicSaved : t.saveLogic}
</Button>
</div>
</Card>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* preview */
/* ------------------------------------------------------------------ */
function Preview({ t, title, subtitle, fields, logic = [], formTitle, formIntro, onAction }: P) {
const id = useId();
const [device, setDevice] = useState<'desktop' | 'phone'>('desktop');
const [vals, setVals] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [scope, enter] = useEnter<HTMLDivElement>();
const shown = fields.filter((f) => {
const r = logic.find((x) => x.show.includes(f.id));
return !r || vals[r.when] === r.equals;
});
const answered = shown.filter((f) => vals[f.id]).length;
const check = () => {
const e: Record<string, string> = {};
for (const f of shown) {
const v = (vals[f.id] ?? '').trim();
if (f.required && !v) e[f.id] = t.requiredError;
else if (f.type === 'email' && v && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) e[f.id] = t.emailError;
}
return e;
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
const e = check();
setErrors(e);
const first = Object.keys(e)[0];
if (first) return document.getElementById(`${id}-p-${first}`)?.focus();
setState('busy');
await run(onAction, 'submit', { answers: vals });
setState('done');
};
const form = (
<form noValidate onSubmit={submit} className={cn('mx-auto w-full', device === 'phone' ? 'max-w-none' : 'max-w-[38rem]')}>
{state === 'done' ? (
<div className="kitm-in py-10 text-center">
<span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground">
<Icon name="check" size={26} strokeWidth={2.4} className="kitm-pop" />
</span>
<h2 className="mt-5 text-[19px] font-semibold tracking-[-0.02em] text-foreground">{t.thanks}</h2>
<p className="mx-auto mt-2 max-w-[26rem] text-pretty text-[14px] text-muted-foreground">{t.thanksText}</p>
<Button
variant="outline"
size="sm"
className="mt-6"
onClick={() => {
setVals({});
setErrors({});
setState('idle');
}}
>
{t.again}
</Button>
</div>
) : (
<>
<h2 className="text-balance text-[22px] font-semibold tracking-[-0.02em] text-foreground">{formTitle}</h2>
{formIntro && <p className="mt-1.5 text-pretty text-[14px] text-muted-foreground">{formIntro}</p>}
<div className="mt-6 grid gap-6">
{shown.map((f) => (
<div key={f.id} className="kitm-in">
<FieldView
f={f}
t={t}
idBase={`${id}-p`}
value={vals[f.id] ?? ''}
error={errors[f.id]}
onChange={(v) => {
setVals((x) => ({ ...x, [f.id]: v }));
if (errors[f.id]) setErrors((x) => ({ ...x, [f.id]: '' }));
}}
/>
</div>
))}
</div>
<div className="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* A line that fills as questions get answered */}
<span className="flex items-center gap-3 text-[12.5px] tabular-nums text-muted-foreground">
<span aria-hidden className="block h-1.5 w-full overflow-hidden rounded-full bg-track sm:w-32">
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(shown.length ? answered / shown.length : 0, 3)})` }} />
</span>
<span className="shrink-0">
{answered} / {shown.length}
</span>
</span>
<Button type="submit" disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : undefined} icon={state === 'busy' ? undefined : 'arrow-right'}>
{state === 'busy' ? t.sending : t.submit}
</Button>
</div>
</>
)}
</form>
);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented
label={t.device}
value={device}
onChange={setDevice}
className="max-md:hidden"
options={[
{ value: 'desktop', label: <><Icon name="monitor" size={14} />{t.desktop}</> },
{ value: 'phone', label: <><Icon name="phone" size={14} />{t.phone}</> },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6">
<div className={cn('kitm-up rounded-xl border border-border bg-surface transition-[padding] duration-500', device === 'phone' ? 'md:px-6 md:py-8' : 'p-0')}>
<div className={cn('mx-auto bg-card transition-[max-width,border-radius] duration-500 ease-[var(--ease-kit)]', device === 'phone' ? 'md:max-w-[24rem] md:rounded-[2rem] md:border-[10px] md:border-bezel md:shadow-[var(--shadow-lg)]' : 'max-w-none rounded-xl')}>
<div className="p-5 sm:p-8">{form}</div>
</div>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* responses */
/* ------------------------------------------------------------------ */
function Responses({ t, title, subtitle, fields, responses = [], now, locale, onAction }: P) {
const [tab, setTab] = useState<'summary' | 'table'>('summary');
const [q, setQ] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const num = (n: number, d = 0) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: d, minimumFractionDigits: d }).format(n);
const day = now.slice(0, 10);
const todayN = responses.filter((r) => r.time.startsWith(day)).length;
const rating = fields.find((f) => f.type === 'rating');
const avg = rating ? responses.reduce((a, r) => a + (Number(r.answers[rating.id]) || 0), 0) / Math.max(1, responses.filter((r) => r.answers[rating.id]).length) : 0;
const complete = responses.filter((r) => fields.filter((f) => f.required).every((f) => r.answers[f.id])).length;
const kpis = [
{ label: t.total, value: num(responses.length) },
{ label: t.today, value: num(todayN) },
{ label: t.completion, value: `${num(responses.length ? (complete / responses.length) * 100 : 0)} %` },
...(rating ? [{ label: t.avgRating, value: `${num(avg, 1)} / 5` }] : []),
];
const shown = responses.filter((r) => !q || Object.values(r.answers).join(' ').toLowerCase().includes(q.toLowerCase()));
const time = (iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(Date.parse(iso + 'Z')));
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" variant="outline" iconLeft="download" onClick={() => void onAction?.('download', {})}>
{t.download}
</Button>
</Header>
<div ref={scope} {...enter} className="mt-6 grid gap-6">
<dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--shadow-sm)] lg:grid-cols-4">
{kpis.map((k, i) => (
<div key={k.label} className="bg-card p-4 sm:p-5">
<div className="kitm-up" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<dt className="text-[12.5px] text-muted-foreground">{k.label}</dt>
<dd className="mt-1 text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">{k.value}</dd>
</div>
</div>
))}
</dl>
<Segmented
label={t.responses}
value={tab}
onChange={setTab}
className="kitm-up w-full sm:w-auto sm:justify-self-start"
options={[
{ value: 'summary', label: t.summary },
{ value: 'table', label: t.table },
]}
/>
{tab === 'summary' ? (
<div key="s" className="kitm-in grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2">
{fields
.filter((f) => f.type === 'choice' || f.type === 'checkbox' || f.type === 'rating')
.map((f, fi) => {
const opts = f.type === 'rating' ? ['5', '4', '3', '2', '1'] : f.options ?? [];
const counts = opts.map((o) => responses.filter((r) => (r.answers[f.id] ?? '').split('|').includes(o)).length);
const max = Math.max(1, ...counts);
const totalAns = responses.filter((r) => r.answers[f.id]).length || 1;
return (
<Card key={f.id} title={f.label} description={fill(t.answersN, { n: num(responses.filter((r) => r.answers[f.id]).length) })}>
<ul className="grid gap-2.5">
{opts.map((o, i) => (
<li key={o} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)_3rem] items-center gap-3 text-[13px] sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_3rem]">
<span className="flex min-w-0 items-center gap-1 truncate text-foreground">
{f.type === 'rating' ? (
<>
{o}
<Icon name="star" size={13} className="text-warning-foreground [&_path]:fill-current" />
</>
) : (
o
)}
</span>
<span className="h-2.5 overflow-hidden rounded-full bg-track">
<span className={cn('kitm-grow-x block h-full rounded-full', counts[i] === max ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_45%,var(--muted))]')} style={{ width: `${round((counts[i] / max) * 100)}%`, '--kitm-d': `${fi * 80 + i * 50}ms` } as React.CSSProperties} />
</span>
<span className="text-right tabular-nums text-muted-foreground">{num((counts[i] / totalAns) * 100)} %</span>
</li>
))}
</ul>
</Card>
);
})}
</div>
) : (
<section key="t" className="kitm-in min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="border-b border-border p-3 sm:p-4">
<label className="relative block sm:max-w-xs">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(control, 'ps-9')} />
</label>
</div>
{/* Tablets and desktop: a table. Phones: one card per response. */}
<div className="hidden md:block">
<table className="w-full table-fixed text-left text-[13px]">
<thead className="border-b border-border bg-surface text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="w-40 px-4 py-2.5 font-medium">
{t.time}
</th>
{fields.slice(0, 4).map((f) => (
<th key={f.id} scope="col" className="truncate px-3 py-2.5 font-medium">
{f.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{shown.map((r) => (
<tr key={r.id} className="hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-muted-foreground">{time(r.time)}</td>
{fields.slice(0, 4).map((f) => (
<td key={f.id} className="truncate px-3 py-3 text-foreground">
{(r.answers[f.id] ?? '').replace(/\|/g, ', ') || <span className="text-subtle-foreground">{t.noAnswer}</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<ul className="divide-y divide-border md:hidden">
{shown.map((r) => (
<li key={r.id} className="px-4 py-3.5">
<p className="text-[12px] tabular-nums text-muted-foreground">{time(r.time)}</p>
<dl className="mt-1.5 grid gap-1">
{fields.slice(0, 4).map((f) => (
<div key={f.id} className="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 text-[13px]">
<dt className="truncate text-muted-foreground">{f.label}</dt>
<dd className="truncate text-foreground">{(r.answers[f.id] ?? '').replace(/\|/g, ', ') || t.noAnswer}</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</section>
)}
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* publish */
/* ------------------------------------------------------------------ */
/** A small seeded random generator (mulberry32, public domain) so the drawn code is the same on server and browser */
function seeded(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = a;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
/** A drawn code in the look of a QR code, for the demo. Pass a real one in production. */
function CodeArt({ text }: { text: string }) {
const n = 25;
const cells = useMemo(() => {
const rnd = seeded([...text].reduce((a, ch) => (a * 31 + ch.charCodeAt(0)) >>> 0, 7));
const out: [number, number][] = [];
const finder = (x: number, y: number) => (x < 8 && y < 8) || (x >= n - 8 && y < 8) || (x < 8 && y >= n - 8);
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (!finder(x, y) && rnd() > 0.52) out.push([x, y]);
return out;
}, [text]);
const eye = (x: number, y: number) => (
<g key={`${x}-${y}`}>
<rect x={x} y={y} width={7} height={7} rx={1.8} fill="currentColor" />
<rect x={x + 1} y={y + 1} width={5} height={5} rx={1.2} fill="#fff" />
<rect x={x + 2} y={y + 2} width={3} height={3} rx={0.8} fill="currentColor" />
</g>
);
return (
<svg viewBox={`-1 -1 ${n + 2} ${n + 2}`} className="size-full text-neutral-900" aria-hidden>
<rect x={-1} y={-1} width={n + 2} height={n + 2} rx={2} fill="#fff" />
{cells.map(([x, y]) => (
<rect key={`${x}.${y}`} x={x + 0.08} y={y + 0.08} width={0.84} height={0.84} rx={0.25} fill="currentColor" />
))}
{eye(0, 0)}
{eye(n - 7, 0)}
{eye(0, n - 7)}
</svg>
);
}
function CopyRow({ value, t, multiline }: { value: string; t: Texts; multiline?: boolean }) {
const [done, setDone] = useState(false);
useEffect(() => {
if (!done) return;
const id = window.setTimeout(() => setDone(false), 1600);
return () => window.clearTimeout(id);
}, [done]);
return (
<div className={cn('flex min-w-0 gap-1 rounded-lg border border-border bg-surface p-1.5 ps-3.5', multiline ? 'items-start' : 'items-center')}>
<code className={cn('min-w-0 flex-1 py-1.5 font-[inherit] text-[13px] text-foreground', multiline ? 'whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]' : 'truncate')}>{value}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
/* The check still shows */
}
setDone(true);
}}
className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8"
>
<Icon key={String(done)} name={done ? 'check' : 'copy'} size={14} className={done ? 'kitm-tick text-success-foreground' : 'kitm-in [--kitm-t:.25s]'} />
<span key={`l${done}`} className="kitm-in [--kitm-t:.25s]">
{done ? t.copied : t.copy}
</span>
</button>
</div>
);
}
function Publish({ t, title, subtitle, url = 'https://forms.example.com/f/abc123', onAction }: P) {
const [state, setState] = useState<'draft' | 'busy' | 'live'>('draft');
const [accept, setAccept] = useState(true);
const [close, setClose] = useState(false);
const [limit, setLimit] = useState(false);
const [notify, setNotify] = useState(true);
const [scope, enter] = useEnter<HTMLDivElement>();
const embed = `<iframe src="${url}?embed=1" width="100%" height="640" style="border:0" title="Form"></iframe>`;
return (
<Main>
<Header title={title} subtitle={subtitle}>
<span className={cn('inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium', state === 'live' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
<span aria-hidden className={cn('size-1.5 rounded-full', state === 'live' ? 'bg-success motion-safe:animate-pulse' : 'bg-subtle-foreground')} />
{state === 'live' ? t.published : t.draft}
</span>
</Header>
{state === 'live' && (
<p role="status" className="kitm-in mt-5 flex items-start gap-2.5 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[13.5px] text-foreground">
<Icon name="check-circle" size={17} className="mt-px shrink-0 text-success-foreground" />
{t.live}
</p>
)}
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<div className="grid min-w-0 gap-6">
<Card title={t.link} className="kitm-up">
<CopyRow value={url} t={t} />
</Card>
<Card title={t.embed} description={t.embedText} className="kitm-up [--kitm-d:80ms]">
<CopyRow value={embed} t={t} multiline />
</Card>
<Card title={t.options_} className="kitm-up [--kitm-d:160ms]" bodyClassName="p-0 sm:p-0">
<ul className="divide-y divide-border">
{[
{ label: t.accept, desc: t.acceptText, on: accept, set: setAccept },
{ label: t.closeOn, desc: '31.12.2026', on: close, set: setClose },
{ label: t.limit, desc: fill(t.limitN, { n: 500 }), on: limit, set: setLimit },
{ label: t.notify, on: notify, set: setNotify },
].map((x) => (
<li key={x.label} className="px-4 py-1 sm:px-5">
<Switch checked={x.on} onCheckedChange={x.set} label={x.label} description={x.desc} className="py-2.5" />
</li>
))}
</ul>
</Card>
</div>
<aside className="kitm-up [--kitm-d:120ms] grid gap-6 lg:sticky lg:top-6">
<Card title={t.qr} description={t.qrText}>
<div className="mx-auto w-40 overflow-hidden rounded-xl border border-border bg-white p-2.5 shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] hover:rotate-[-2deg] hover:scale-[1.03]">
<CodeArt text={url} />
</div>
</Card>
<Button
className="w-full"
disabled={state !== 'draft' || !accept}
iconLeft={state === 'busy' ? 'loader' : state === 'live' ? 'check' : 'rocket'}
onClick={async () => {
setState('busy');
await run(onAction, 'publish', { accept, close, limit, notify });
setState('live');
}}
>
{state === 'busy' ? t.going : state === 'live' ? t.published : t.goLive}
</Button>
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* FormBuilder */
/* ------------------------------------------------------------------ */
/**
* Form builder screens: fields to add, order and set up, logic that shows
* questions for fitting answers, a live preview on desktop and phone,
* responses with a summary and a table, and publishing with link, embed
* code and QR code.
* <FormBuilder variant="builder" title="Feedback" fields={[{ id, type, label, required, options }]} />
*/
export function FormBuilder({ variant = 'builder', sidebar, labels, className, fields = [], now = '2026-10-19T14:30', ...props }: FormBuilderProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: P = { ...props, t, locale, fields, now };
const body = (() => {
switch (variant) {
case 'logic':
return <Logic {...p} />;
case 'preview':
return <Preview {...p} />;
case 'responses':
return <Responses {...p} />;
case 'publish':
return <Publish {...p} />;
default:
return <Builder {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Häufige Fragen
Wie installiere ich Form Builder?
Mit aiw add @ai-website/form-builder. Der Befehl legt die Datei components/ui/form-builder.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, switch und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Form Builder?
5 Varianten, Builder, Logik, Live-Vorschau, Antworten und Veröffentlichen. Du wählst sie mit der Prop variant, zum Beispiel variant="logic".
Kann ich die Texte von Form Builder übersetzen?
Ja. Form Builder bringt 84 eigene Texte auf Deutsch und Englisch mit, etwa „Kurzer Text“, „E-Mail“ und „Langer Text“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Form Builder einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Form Builder mit aiw_components und installieren sie mit aiw_add @ai-website/form-builder. Nenn im Prompt die Variante, etwa variant="builder", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Form Builder ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden FormBuilder-Code zum Kopieren.