Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/formWidget/builder/PreviewTab.tsx
T

226 lines
8.0 KiB
TypeScript

/**
* Preview tab for the form-widget builder. Renders the in-flight
* definition using the SAME field-rendering pipeline + inline CSS as
* the embed route at `/embed/forms/$formKey`. That is intentional:
* what the customer sees in their iframe must match what the operator
* sees in the admin preview.
*
* The component is deliberately self-contained — it is rendered
* outside the sandboxed iframe so no postMessage / parent-resize
* concerns apply, and form-submission is disabled (preview is for
* visual inspection only).
*/
import type * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { HONEYPOT_FIELD_KEY, type FormDefinition, type FormField, type FormTheme } from "@toolctrl/form-widget";
export type PreviewTabProps = {
definition: FormDefinition;
theme: FormTheme;
};
function themeCssVars(theme: FormTheme): React.CSSProperties {
const vars: Record<string, string> = {};
if (theme.primaryColor) vars["--tcform-primary"] = theme.primaryColor;
if (theme.backgroundColor) vars["--tcform-bg"] = theme.backgroundColor;
if (theme.textColor) vars["--tcform-text"] = theme.textColor;
if (theme.fontFamily) vars["--tcform-font"] = theme.fontFamily;
if (theme.borderRadius) vars["--tcform-radius"] = theme.borderRadius;
return vars as React.CSSProperties;
}
function PreviewField({
field,
value,
onChange,
}: {
field: FormField;
value: string | boolean;
onChange: (next: string | boolean) => void;
}): React.JSX.Element | null {
const inputId = `tcform-preview-${field.key}`;
if (field.type === "honeypot") {
return (
<input
type="text"
name={field.key}
tabIndex={-1}
aria-hidden="true"
autoComplete="off"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
/>
);
}
const labelEl = (
<label htmlFor={inputId} className="tcform-label">
{field.label}
{field.required ? <span aria-hidden="true">{" *"}</span> : null}
</label>
);
const helpEl = field.helpText ? (
<div className="tcform-help" id={`${inputId}-help`}>
{field.helpText}
</div>
) : null;
if (field.type === "textarea") {
return (
<div className="tcform-row">
{labelEl}
<textarea
id={inputId}
name={field.key}
required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
minLength={field.minLength}
aria-describedby={helpEl ? `${inputId}-help` : undefined}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
{helpEl}
</div>
);
}
if (field.type === "select") {
const options = field.options ?? [];
return (
<div className="tcform-row">
{labelEl}
<select
id={inputId}
name={field.key}
required={field.required}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{field.placeholder ?? ""}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{helpEl}
</div>
);
}
if (field.type === "radio") {
const options = field.options ?? [];
return (
<fieldset className="tcform-row tcform-radio-group" aria-describedby={helpEl ? `${inputId}-help` : undefined}>
<legend>{field.label}</legend>
{options.map((opt) => (
<label key={opt.value} className="tcform-radio">
<input
type="radio"
name={field.key}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
required={field.required}
/>
<span>{opt.label}</span>
</label>
))}
{helpEl}
</fieldset>
);
}
if (field.type === "checkbox") {
return (
<div className="tcform-row tcform-checkbox">
<label className="tcform-label">
<input
type="checkbox"
name={field.key}
checked={value === true}
onChange={(e) => onChange(e.target.checked)}
required={field.required}
/>
<span>{field.label}</span>
</label>
{helpEl}
</div>
);
}
const inputType = field.type === "email" ? "email" : field.type === "number" ? "number" : "text";
return (
<div className="tcform-row">
{labelEl}
<input
id={inputId}
type={inputType}
name={field.key}
required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
minLength={field.minLength}
aria-describedby={helpEl ? `${inputId}-help` : undefined}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
{helpEl}
</div>
);
}
export function PreviewTab({ definition, theme }: PreviewTabProps): React.JSX.Element {
const { t } = useTranslation();
const styleVars = useMemo(() => themeCssVars(theme), [theme]);
const [values, setValues] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
for (const f of definition.fields) {
init[f.key] = f.type === "checkbox" ? false : "";
}
return init;
});
return (
<div className="border rounded bg-white p-3 max-w-xl">
<p className="text-[11px] text-gray-500 mb-2">
{t(
"settings.forms.builder.preview.description",
"Preview renders with the same CSS variables and field components as the embedded form route. Submission is disabled.",
)}
</p>
<div className="tcform-root" style={styleVars}>
<style>{`
.tcform-root { font-family: var(--tcform-font, system-ui, sans-serif); background: var(--tcform-bg, #fff); color: var(--tcform-text, #111); padding: 16px; box-sizing: border-box; border: 1px dashed #e5e7eb; border-radius: 6px; }
.tcform-root h1 { font-size: 1.25rem; margin: 0 0 0.25em; }
.tcform-root p.tcform-description { margin: 0 0 1em; opacity: 0.8; }
.tcform-row { display: block; margin-bottom: 12px; }
.tcform-root .tcform-label { display: block; font-weight: 500; margin-bottom: 4px; }
.tcform-root input[type=text], .tcform-root input[type=email], .tcform-root input[type=number], .tcform-root textarea, .tcform-root select { width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: var(--tcform-radius, 6px); font: inherit; color: inherit; background: #fff; box-sizing: border-box; }
.tcform-root textarea { min-height: 100px; resize: vertical; }
.tcform-root .tcform-help { font-size: 0.85em; opacity: 0.7; margin-top: 4px; }
.tcform-root button { background: var(--tcform-primary, #2563eb); color: #fff; border: 0; padding: 10px 16px; border-radius: var(--tcform-radius, 6px); cursor: pointer; font: inherit; }
`}</style>
<h1>{definition.title}</h1>
{definition.description ? <p className="tcform-description">{definition.description}</p> : null}
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
{definition.fields.map((field) => (
<PreviewField
key={field.key}
field={field}
value={values[field.key] ?? (field.type === "checkbox" ? false : "")}
onChange={(next) => setValues((prev) => ({ ...prev, [field.key]: next }))}
/>
))}
<input type="hidden" name="__hp_key" value={HONEYPOT_FIELD_KEY} />
<button type="button" disabled>
{definition.submitLabel}
</button>
</form>
</div>
</div>
);
}