diff --git a/src/app/globals.css b/src/app/globals.css index 5960e44..0b2b865 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/globals.test.ts b/src/app/globals.test.ts new file mode 100644 index 0000000..6eda86d --- /dev/null +++ b/src/app/globals.test.ts @@ -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); + }); +}); diff --git a/src/components/builder/Builder.tsx b/src/components/builder/Builder.tsx index ecd61a7..89ef338 100644 --- a/src/components/builder/Builder.tsx +++ b/src/components/builder/Builder.tsx @@ -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(null); + const resizeStart = useRef<{ pointerId: number; y: number; height: number } | null>(null); + const resizeCurrentHeight = useRef(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) => { + 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 = ( + + ); + if (field.type === "page_break") { return (
  • + {resizeHandle} {children}
  • ); @@ -627,7 +710,7 @@ function FieldRow({ field, selected, registerInput, onSelect, onLabel, onRemove, return (
  • )} -
    +
    {/* Label row */} -
    +
    {/* Hover action toolbar */} -
    {children} + {resizeHandle}
  • ); } diff --git a/src/components/runtime/FormRuntime.tsx b/src/components/runtime/FormRuntime.tsx index c91a60c..2959824 100644 --- a/src/components/runtime/FormRuntime.tsx +++ b/src/components/runtime/FormRuntime.tsx @@ -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(() => { - 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( + () => 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]); diff --git a/src/lib/field-block-height.test.ts b/src/lib/field-block-height.test.ts new file mode 100644 index 0000000..8c28ddc --- /dev/null +++ b/src/lib/field-block-height.test.ts @@ -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); + }); +}); diff --git a/src/lib/field-block-height.ts b/src/lib/field-block-height.ts new file mode 100644 index 0000000..50b39db --- /dev/null +++ b/src/lib/field-block-height.ts @@ -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))); +} diff --git a/src/lib/form-pages.test.ts b/src/lib/form-pages.test.ts new file mode 100644 index 0000000..f8e8eb2 --- /dev/null +++ b/src/lib/form-pages.test.ts @@ -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"], + ]); + }); +}); diff --git a/src/lib/form-pages.ts b/src/lib/form-pages.ts new file mode 100644 index 0000000..2da42c5 --- /dev/null +++ b/src/lib/form-pages.ts @@ -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 : [[]]; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 1bbc0f7..5577d65 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[];