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-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;
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user