Polish form builder field UI

This commit is contained in:
Zachariah K. Sharma
2026-06-13 11:52:07 -06:00
parent 4d45516c13
commit dd8456b34d
9 changed files with 234 additions and 24 deletions
+20
View File
@@ -121,6 +121,26 @@ input, textarea, select { background: transparent; }
.dark .chip-media { background: rgb(154 52 18 / 0.3); color: rgb(253 186 116); } .dark .chip-media { background: rgb(154 52 18 / 0.3); color: rgb(253 186 116); }
.dark .chip-special { background: rgb(var(--line)); color: rgb(var(--muted)); } .dark .chip-special { background: rgb(var(--line)); color: rgb(var(--muted)); }
/* ── Builder field cards ────────────────────────────────────── */
.builder-field-card {
--builder-field-toolbar-width: 13rem;
container-type: inline-size;
}
.builder-field-label-row {
padding-right: var(--builder-field-toolbar-width);
}
.builder-field-label-input {
min-width: 0;
}
@container (max-width: 30rem) {
.builder-field-body {
padding-top: 3rem;
}
.builder-field-label-row {
padding-right: 0;
}
}
/* ── Form runtime inputs ─────────────────────────────────────── */ /* ── Form runtime inputs ─────────────────────────────────────── */
.field-label { .field-label {
font-size: 15px; font-weight: 500; color: rgb(var(--fg)); line-height: 1.4; font-size: 15px; font-weight: 500; color: rgb(var(--fg)); line-height: 1.4;
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const css = readFileSync(new URL("./globals.css", import.meta.url), "utf8");
describe("builder field label layout", () => {
it("reserves space for the absolute action toolbar and lets the label input shrink", () => {
expect(css).toMatch(/\.builder-field-card\s*\{[^}]*container-type:\s*inline-size/s);
expect(css).toMatch(/\.builder-field-label-row\s*\{[^}]*padding-right:\s*var\(--builder-field-toolbar-width\)/s);
expect(css).toMatch(/\.builder-field-label-input\s*\{[^}]*min-width:\s*0/s);
});
it("stacks the label below the toolbar when the field card is narrow", () => {
expect(css).toMatch(/@container\s*\(max-width:\s*30rem\)/);
expect(css).toMatch(/@container[\s\S]*\.builder-field-body\s*\{[^}]*padding-top:\s*3rem/s);
expect(css).toMatch(/@container[\s\S]*\.builder-field-label-row\s*\{[^}]*padding-right:\s*0/s);
});
});
+91 -7
View File
@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
import { saveForm, deleteForm, setViewers } from "@/lib/actions"; import { saveForm, deleteForm, setViewers } from "@/lib/actions";
import type { Field, FieldType, FormSettings, LogicOp, LogicGroup, LogicRule } from "@/lib/types"; import type { Field, FieldType, FormSettings, LogicOp, LogicGroup, LogicRule } from "@/lib/types";
import { FIELD_TYPE_LABELS, normalizeShowIf } from "@/lib/types"; import { FIELD_TYPE_LABELS, normalizeShowIf } from "@/lib/types";
import { resizeFieldBlock } from "@/lib/field-block-height";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input, Textarea } from "@/components/ui/Input"; import { Input, Textarea } from "@/components/ui/Input";
import { Toggle } from "@/components/ui/Toggle"; import { Toggle } from "@/components/ui/Toggle";
@@ -198,6 +199,7 @@ export default function Builder({ form: initial, members, currentUserId, publicF
next.id = x.id; next.id = x.id;
if (x.required) next.required = true; if (x.required) next.required = true;
if (x.help) next.help = x.help; if (x.help) next.help = x.help;
if (x.height) next.height = x.height;
return next; return next;
}), }),
})); }));
@@ -258,6 +260,7 @@ export default function Builder({ form: initial, members, currentUserId, publicF
registerInput={(el) => registerLabelInput(f.id, el)} registerInput={(el) => registerLabelInput(f.id, el)}
onSelect={() => { setSelectedId(f.id); setRightTab("field"); }} onSelect={() => { setSelectedId(f.id); setRightTab("field"); }}
onLabel={(label) => updateField(f.id, { label })} onLabel={(label) => updateField(f.id, { label })}
onHeight={(height) => updateField(f.id, { height })}
onRemove={() => removeField(f.id)} onRemove={() => removeField(f.id)}
onDuplicate={() => duplicateField(f.id)} onDuplicate={() => duplicateField(f.id)}
onOpenSlash={() => setSlashFor(f.id)} onOpenSlash={() => setSlashFor(f.id)}
@@ -581,12 +584,13 @@ function FormHeaderEditor({ form, onChange }: { form: FormData; onChange: (p: Pa
// ── WYSIWYG field row ────────────────────────────────────────────────────── // ── WYSIWYG field row ──────────────────────────────────────────────────────
function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove, onDuplicate, onOpenSlash, onKey, children }: { function FieldRow({ field, selected, registerInput, onSelect, onLabel, onHeight, onRemove, onDuplicate, onOpenSlash, onKey, children }: {
field: Field; field: Field;
selected: boolean; selected: boolean;
registerInput: (el: HTMLInputElement | null) => void; registerInput: (el: HTMLInputElement | null) => void;
onSelect: () => void; onSelect: () => void;
onLabel: (label: string) => void; onLabel: (label: string) => void;
onHeight: (height: number) => void;
onRemove: () => void; onRemove: () => void;
onDuplicate: () => void; onDuplicate: () => void;
onOpenSlash: () => void; onOpenSlash: () => void;
@@ -594,9 +598,87 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ id: field.id }); const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ id: field.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }; const [resizingHeight, setResizingHeight] = useState<number | null>(null);
const resizeStart = useRef<{ pointerId: number; y: number; height: number } | null>(null);
const resizeCurrentHeight = useRef<number | null>(null);
const resizeCleanup = useRef<(() => void) | null>(null);
const height = resizingHeight ?? field.height;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
height: height ? `${height}px` : undefined,
};
const groups = normalizeShowIf(field.showIf); const groups = normalizeShowIf(field.showIf);
useEffect(() => () => resizeCleanup.current?.(), []);
const startResize = (e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const row = e.currentTarget.parentElement;
if (!row) return;
const startHeight = row.getBoundingClientRect().height;
resizeStart.current = { pointerId: e.pointerId, y: e.clientY, height: startHeight };
resizeCurrentHeight.current = startHeight;
e.currentTarget.setPointerCapture(e.pointerId);
setResizingHeight(startHeight);
resizeCleanup.current?.();
const move = (ev: PointerEvent) => {
if (ev.pointerId !== e.pointerId) return;
const nextHeight = resizeFieldBlock(startHeight, ev.clientY - e.clientY);
resizeCurrentHeight.current = nextHeight;
setResizingHeight(nextHeight);
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
resizeCleanup.current = null;
};
const up = (ev: PointerEvent) => {
if (ev.pointerId !== e.pointerId) return;
const nextHeight = resizeCurrentHeight.current ?? resizeFieldBlock(startHeight, ev.clientY - e.clientY);
cleanup();
resizeStart.current = null;
resizeCurrentHeight.current = null;
setResizingHeight(null);
onHeight(nextHeight);
};
const cancel = (ev: PointerEvent) => {
if (ev.pointerId !== e.pointerId) return;
cleanup();
resizeStart.current = null;
resizeCurrentHeight.current = null;
setResizingHeight(null);
};
resizeCleanup.current = cleanup;
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancel);
};
const resizeHandle = (
<button
type="button"
aria-label="Resize field height"
title="Drag to resize field"
onClick={(e) => e.stopPropagation()}
onPointerDown={startResize}
className={`absolute bottom-0 left-1/2 z-20 flex h-3 w-24 -translate-x-1/2
touch-none cursor-ns-resize items-end justify-center pb-1 transition-opacity
${selected || resizingHeight !== null ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
>
<span className="h-0.5 w-10 rounded-full bg-[rgb(var(--muted)/0.45)]" />
</button>
);
if (field.type === "page_break") { if (field.type === "page_break") {
return ( return (
<li ref={setNodeRef} style={style} <li ref={setNodeRef} style={style}
@@ -620,6 +702,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
<Trash2 size={13}/> <Trash2 size={13}/>
</button> </button>
</div> </div>
{resizeHandle}
{children} {children}
</li> </li>
); );
@@ -627,7 +710,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
return ( return (
<li ref={setNodeRef} style={style} onClick={onSelect} <li ref={setNodeRef} style={style} onClick={onSelect}
className={`group relative bg-[rgb(var(--surface))] rounded-xl border cursor-pointer className={`builder-field-card group relative bg-[rgb(var(--surface))] rounded-xl border cursor-pointer
transition-all duration-150 overflow-hidden transition-all duration-150 overflow-hidden
${selected ${selected
? "border-[rgb(var(--accent)/0.5)] shadow-[var(--shadow-md)] ring-2 ring-[rgb(var(--accent)/0.12)]" ? "border-[rgb(var(--accent)/0.5)] shadow-[var(--shadow-md)] ring-2 ring-[rgb(var(--accent)/0.12)]"
@@ -639,9 +722,9 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-[rgb(var(--accent))] rounded-l-full" /> <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-[rgb(var(--accent))] rounded-l-full" />
)} )}
<div className="px-4 pt-3.5 pb-3.5"> <div className="builder-field-body px-4 pt-3.5 pb-3.5">
{/* Label row */} {/* Label row */}
<div className="flex items-center gap-2 mb-2.5"> <div className="builder-field-label-row flex items-center gap-2 mb-2.5">
<button {...attributes} {...listeners} <button {...attributes} {...listeners}
className="cursor-grab text-[rgb(var(--muted))] opacity-0 group-hover:opacity-40 hover:!opacity-80 shrink-0 -ml-1" className="cursor-grab text-[rgb(var(--muted))] opacity-0 group-hover:opacity-40 hover:!opacity-80 shrink-0 -ml-1"
type="button" aria-label="Drag"> type="button" aria-label="Drag">
@@ -654,7 +737,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
onKeyDown={onKey} onKeyDown={onKey}
onFocus={onSelect} onFocus={onSelect}
placeholder="Field label" placeholder="Field label"
className="flex-1 text-sm font-semibold bg-transparent border-0 focus:outline-none focus:ring-0 className="builder-field-label-input flex-1 text-sm font-semibold bg-transparent border-0 focus:outline-none focus:ring-0
text-[rgb(var(--fg))] placeholder:text-[rgb(var(--muted)/0.5)]" text-[rgb(var(--fg))] placeholder:text-[rgb(var(--muted)/0.5)]"
/> />
{field.required && <span className="text-[rgb(var(--danger))] font-bold shrink-0">*</span>} {field.required && <span className="text-[rgb(var(--danger))] font-bold shrink-0">*</span>}
@@ -670,7 +753,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
</div> </div>
{/* Hover action toolbar */} {/* Hover action toolbar */}
<div className={`absolute top-2 right-2 flex items-center gap-0.5 <div className={`builder-field-toolbar absolute top-2 right-2 flex items-center gap-0.5
${selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}> ${selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}>
<button onClick={(e) => { e.stopPropagation(); onOpenSlash(); }} <button onClick={(e) => { e.stopPropagation(); onOpenSlash(); }}
className="p-1.5 rounded-lg text-[rgb(var(--muted))] hover:text-[rgb(var(--fg))] className="p-1.5 rounded-lg text-[rgb(var(--muted))] hover:text-[rgb(var(--fg))]
@@ -692,6 +775,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
</div> </div>
{children} {children}
{resizeHandle}
</li> </li>
); );
} }
+7 -17
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Field, FileRef, FormSettings } from "@/lib/types"; import type { Field, FileRef, FormSettings } from "@/lib/types";
import { visibleFields } from "@/lib/logic"; import { visibleFields } from "@/lib/logic";
import { applyCalculations } from "@/lib/calc"; import { applyCalculations } from "@/lib/calc";
import { splitFormPages } from "@/lib/form-pages";
import { pipe } from "@/lib/pipe"; import { pipe } from "@/lib/pipe";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input, Textarea } from "@/components/ui/Input"; import { Input, Textarea } from "@/components/ui/Input";
@@ -107,23 +108,12 @@ export function FormRuntime({
const computed = useMemo(() => applyCalculations(fields, values), [fields, values]); const computed = useMemo(() => applyCalculations(fields, values), [fields, values]);
const shown = useMemo(() => visibleFields(fields, computed), [fields, computed]); const shown = useMemo(() => visibleFields(fields, computed), [fields, computed]);
// Split visible fields into pages based on layout setting + page_break markers. // Explicit page breaks are structural, while forms without them retain their
// Drops page_break fields from the rendered output — they're structural only. // selected layout behavior.
const pages = useMemo<Field[][]>(() => { const pages = useMemo<Field[][]>(
const layout = settings.layout ?? "one_page"; () => splitFormPages(shown, settings.layout),
const renderable = shown.filter((f) => f.type !== "page_break"); [shown, settings.layout],
if (layout === "one_page") return [renderable]; );
if (layout === "one_at_a_time") return renderable.map((f) => [f]);
// "paged" — split at page_break markers using their position in `shown`.
const out: Field[][] = [];
let current: Field[] = [];
for (const f of shown) {
if (f.type === "page_break") { if (current.length) out.push(current); current = []; }
else current.push(f);
}
if (current.length) out.push(current);
return out.length ? out : [[]];
}, [shown, settings.layout]);
const [pageIdx, setPageIdx] = useState(0); const [pageIdx, setPageIdx] = useState(0);
useEffect(() => { if (pageIdx >= pages.length) setPageIdx(Math.max(0, pages.length - 1)); }, [pageIdx, pages.length]); useEffect(() => { if (pageIdx >= pages.length) setPageIdx(Math.max(0, pages.length - 1)); }, [pageIdx, pages.length]);
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
MAX_FIELD_BLOCK_HEIGHT,
MIN_FIELD_BLOCK_HEIGHT,
resizeFieldBlock,
} from "./field-block-height";
describe("resizeFieldBlock", () => {
it("adds the vertical pointer delta to the starting height", () => {
expect(resizeFieldBlock(140, 35)).toBe(175);
expect(resizeFieldBlock(140, -20)).toBe(120);
});
it("clamps resized blocks to the supported range", () => {
expect(resizeFieldBlock(140, -500)).toBe(MIN_FIELD_BLOCK_HEIGHT);
expect(resizeFieldBlock(140, 1_000)).toBe(MAX_FIELD_BLOCK_HEIGHT);
});
it("rounds persisted heights to whole pixels", () => {
expect(resizeFieldBlock(140.2, 10.4)).toBe(151);
});
});
+6
View File
@@ -0,0 +1,6 @@
export const MIN_FIELD_BLOCK_HEIGHT = 64;
export const MAX_FIELD_BLOCK_HEIGHT = 720;
export function resizeFieldBlock(startHeight: number, verticalDelta: number): number {
return Math.round(Math.min(MAX_FIELD_BLOCK_HEIGHT, Math.max(MIN_FIELD_BLOCK_HEIGHT, startHeight + verticalDelta)));
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { Field } from "./types";
import { splitFormPages } from "./form-pages";
const field = (id: string, type: Field["type"] = "short_text"): Field => ({
id,
type,
label: id,
});
describe("splitFormPages", () => {
it("splits explicit page breaks even when the layout defaults to one page", () => {
const pages = splitFormPages([
field("first"),
field("break", "page_break"),
field("second"),
]);
expect(pages.map((page) => page.map(({ id }) => id))).toEqual([
["first"],
["second"],
]);
});
it("keeps forms without page breaks on one page", () => {
const pages = splitFormPages([field("first"), field("second")], "one_page");
expect(pages.map((page) => page.map(({ id }) => id))).toEqual([
["first", "second"],
]);
});
it("keeps one-at-a-time layout as one field per page", () => {
const pages = splitFormPages([
field("first"),
field("break", "page_break"),
field("second"),
], "one_at_a_time");
expect(pages.map((page) => page.map(({ id }) => id))).toEqual([
["first"],
["second"],
]);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { Field, FormLayout } from "./types";
export function splitFormPages(fields: Field[], layout: FormLayout = "one_page"): Field[][] {
const renderable = fields.filter((field) => field.type !== "page_break");
if (layout === "one_at_a_time") return renderable.map((field) => [field]);
if (!fields.some((field) => field.type === "page_break")) return [renderable];
const pages: Field[][] = [];
let current: Field[] = [];
for (const field of fields) {
if (field.type === "page_break") {
if (current.length) pages.push(current);
current = [];
} else {
current.push(field);
}
}
if (current.length) pages.push(current);
return pages.length ? pages : [[]];
}
+2
View File
@@ -57,6 +57,8 @@ export type Field = {
maxSizeMB?: number; maxSizeMB?: number;
/** Calculated-only: expression evaluated against other field values. */ /** Calculated-only: expression evaluated against other field values. */
expression?: string; expression?: string;
/** User-selected field block height in pixels. */
height?: number;
// Visibility rules. The new shape is LogicGroup[] (groups ORed). The legacy // Visibility rules. The new shape is LogicGroup[] (groups ORed). The legacy
// shape LogicRule[] is auto-upgraded by normalizeShowIf(). // shape LogicRule[] is auto-upgraded by normalizeShowIf().
showIf?: LogicGroup[] | LogicRule[]; showIf?: LogicGroup[] | LogicRule[];