Polish form builder field UI
This commit is contained in:
@@ -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-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 ─────────────────────────────────────── */
|
||||
.field-label {
|
||||
font-size: 15px; font-weight: 500; color: rgb(var(--fg)); line-height: 1.4;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
|
||||
import { saveForm, deleteForm, setViewers } from "@/lib/actions";
|
||||
import type { Field, FieldType, FormSettings, LogicOp, LogicGroup, LogicRule } 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 { Input, Textarea } from "@/components/ui/Input";
|
||||
import { Toggle } from "@/components/ui/Toggle";
|
||||
@@ -198,6 +199,7 @@ export default function Builder({ form: initial, members, currentUserId, publicF
|
||||
next.id = x.id;
|
||||
if (x.required) next.required = true;
|
||||
if (x.help) next.help = x.help;
|
||||
if (x.height) next.height = x.height;
|
||||
return next;
|
||||
}),
|
||||
}));
|
||||
@@ -258,6 +260,7 @@ export default function Builder({ form: initial, members, currentUserId, publicF
|
||||
registerInput={(el) => registerLabelInput(f.id, el)}
|
||||
onSelect={() => { setSelectedId(f.id); setRightTab("field"); }}
|
||||
onLabel={(label) => updateField(f.id, { label })}
|
||||
onHeight={(height) => updateField(f.id, { height })}
|
||||
onRemove={() => removeField(f.id)}
|
||||
onDuplicate={() => duplicateField(f.id)}
|
||||
onOpenSlash={() => setSlashFor(f.id)}
|
||||
@@ -581,12 +584,13 @@ function FormHeaderEditor({ form, onChange }: { form: FormData; onChange: (p: Pa
|
||||
|
||||
// ── 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;
|
||||
selected: boolean;
|
||||
registerInput: (el: HTMLInputElement | null) => void;
|
||||
onSelect: () => void;
|
||||
onLabel: (label: string) => void;
|
||||
onHeight: (height: number) => void;
|
||||
onRemove: () => void;
|
||||
onDuplicate: () => void;
|
||||
onOpenSlash: () => void;
|
||||
@@ -594,9 +598,87 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
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);
|
||||
|
||||
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") {
|
||||
return (
|
||||
<li ref={setNodeRef} style={style}
|
||||
@@ -620,6 +702,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
</div>
|
||||
{resizeHandle}
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
@@ -627,7 +710,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
|
||||
|
||||
return (
|
||||
<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
|
||||
${selected
|
||||
? "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="px-4 pt-3.5 pb-3.5">
|
||||
<div className="builder-field-body px-4 pt-3.5 pb-3.5">
|
||||
{/* 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}
|
||||
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">
|
||||
@@ -654,7 +737,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove,
|
||||
onKeyDown={onKey}
|
||||
onFocus={onSelect}
|
||||
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)]"
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* 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`}>
|
||||
<button onClick={(e) => { e.stopPropagation(); onOpenSlash(); }}
|
||||
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>
|
||||
|
||||
{children}
|
||||
{resizeHandle}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Field, FileRef, FormSettings } from "@/lib/types";
|
||||
import { visibleFields } from "@/lib/logic";
|
||||
import { applyCalculations } from "@/lib/calc";
|
||||
import { splitFormPages } from "@/lib/form-pages";
|
||||
import { pipe } from "@/lib/pipe";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Textarea } from "@/components/ui/Input";
|
||||
@@ -107,23 +108,12 @@ export function FormRuntime({
|
||||
const computed = useMemo(() => applyCalculations(fields, values), [fields, values]);
|
||||
const shown = useMemo(() => visibleFields(fields, computed), [fields, computed]);
|
||||
|
||||
// Split visible fields into pages based on layout setting + page_break markers.
|
||||
// Drops page_break fields from the rendered output — they're structural only.
|
||||
const pages = useMemo<Field[][]>(() => {
|
||||
const layout = settings.layout ?? "one_page";
|
||||
const renderable = shown.filter((f) => f.type !== "page_break");
|
||||
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]);
|
||||
// Explicit page breaks are structural, while forms without them retain their
|
||||
// selected layout behavior.
|
||||
const pages = useMemo<Field[][]>(
|
||||
() => splitFormPages(shown, settings.layout),
|
||||
[shown, settings.layout],
|
||||
);
|
||||
|
||||
const [pageIdx, setPageIdx] = useState(0);
|
||||
useEffect(() => { if (pageIdx >= pages.length) setPageIdx(Math.max(0, pages.length - 1)); }, [pageIdx, pages.length]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 : [[]];
|
||||
}
|
||||
@@ -57,6 +57,8 @@ export type Field = {
|
||||
maxSizeMB?: number;
|
||||
/** Calculated-only: expression evaluated against other field values. */
|
||||
expression?: string;
|
||||
/** User-selected field block height in pixels. */
|
||||
height?: number;
|
||||
// Visibility rules. The new shape is LogicGroup[] (groups ORed). The legacy
|
||||
// shape LogicRule[] is auto-upgraded by normalizeShowIf().
|
||||
showIf?: LogicGroup[] | LogicRule[];
|
||||
|
||||
Reference in New Issue
Block a user