208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
/**
|
|
* Right-rail field-properties editor for the form-builder. Renders the
|
|
* full editable surface for the currently-selected field — label, key,
|
|
* required, placeholder, help-text, length validation and per-option
|
|
* editing for select/radio types.
|
|
*
|
|
* The component is a controlled view: every input change calls back
|
|
* through `onChange` with a fresh field object so the parent
|
|
* `<FormBuilder/>` can persist + dispatch the canvas update.
|
|
*/
|
|
import type * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { FormField } from "@toolctrl/form-widget";
|
|
|
|
export type FieldPropertiesProps = {
|
|
field: FormField | null;
|
|
onChange: (next: FormField) => void;
|
|
};
|
|
|
|
function NumericInput({
|
|
fieldId,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
fieldId: string;
|
|
value: number | undefined;
|
|
onChange: (next: number | undefined) => void;
|
|
}): React.JSX.Element {
|
|
return (
|
|
<input
|
|
id={fieldId}
|
|
type="number"
|
|
min={0}
|
|
className="border rounded px-2 py-1 w-full"
|
|
value={value ?? ""}
|
|
onChange={(e) => {
|
|
const raw = e.target.value.trim();
|
|
if (raw === "") return onChange(undefined);
|
|
const n = Number.parseInt(raw, 10);
|
|
onChange(Number.isFinite(n) ? n : undefined);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function OptionsEditor({
|
|
options,
|
|
onChange,
|
|
}: {
|
|
options: { value: string; label: string }[];
|
|
onChange: (next: { value: string; label: string }[]) => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 text-xs uppercase tracking-wide text-gray-500">
|
|
<span>{t("settings.forms.builder.properties.options.value")}</span>
|
|
<span>{t("settings.forms.builder.properties.options.label")}</span>
|
|
<span aria-hidden="true">{" "}</span>
|
|
</div>
|
|
{options.map((opt, idx) => (
|
|
<div key={idx} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
|
<input
|
|
className="border rounded px-2 py-1 text-sm"
|
|
value={opt.value}
|
|
onChange={(e) => {
|
|
const next = options.slice();
|
|
next[idx] = { ...opt, value: e.target.value };
|
|
onChange(next);
|
|
}}
|
|
aria-label={t("settings.forms.builder.properties.options.valueAria", { number: idx + 1 })}
|
|
/>
|
|
<input
|
|
className="border rounded px-2 py-1 text-sm"
|
|
value={opt.label}
|
|
onChange={(e) => {
|
|
const next = options.slice();
|
|
next[idx] = { ...opt, label: e.target.value };
|
|
onChange(next);
|
|
}}
|
|
aria-label={t("settings.forms.builder.properties.options.labelAria", { number: idx + 1 })}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="text-red-600 px-2 text-xs"
|
|
onClick={() => onChange(options.filter((_, i) => i !== idx))}
|
|
aria-label={t("settings.forms.builder.properties.options.removeAria", { number: idx + 1 })}
|
|
>
|
|
{"✕"}
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="text-sm border rounded px-2 py-1"
|
|
onClick={() =>
|
|
onChange([
|
|
...options,
|
|
{
|
|
value: `opt_${options.length + 1}`,
|
|
label: t("settings.forms.builder.properties.options.defaultLabel", { number: options.length + 1 }),
|
|
},
|
|
])
|
|
}
|
|
>
|
|
{t("settings.forms.builder.properties.options.add")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FieldProperties({ field, onChange }: FieldPropertiesProps): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
if (!field) {
|
|
return (
|
|
<aside className="w-full shrink-0 border rounded p-3 bg-white lg:w-72" aria-label={t("settings.forms.builder.properties.ariaLabel")}>
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.properties.title")}</h3>
|
|
<p className="text-sm text-gray-500 mt-2">{t("settings.forms.builder.properties.empty")}</p>
|
|
</aside>
|
|
);
|
|
}
|
|
const supportsOptions = field.type === "select" || field.type === "radio";
|
|
const supportsPlaceholder = field.type === "text" || field.type === "email" || field.type === "number" || field.type === "textarea" || field.type === "select";
|
|
const supportsLength = field.type === "text" || field.type === "email" || field.type === "textarea";
|
|
return (
|
|
<aside className="w-full shrink-0 border rounded p-3 bg-white space-y-3 lg:w-72" aria-label={t("settings.forms.builder.properties.ariaLabel")}>
|
|
<header>
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.properties.title")}</h3>
|
|
<p className="text-[11px] text-gray-500">{field.type}</p>
|
|
</header>
|
|
<label className="block text-sm">
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.label")}</span>
|
|
<input
|
|
className="border rounded px-2 py-1 w-full"
|
|
value={field.label}
|
|
onChange={(e) => onChange({ ...field, label: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.fieldKey")}</span>
|
|
<input
|
|
className="border rounded px-2 py-1 w-full font-mono text-xs"
|
|
value={field.key}
|
|
onChange={(e) => onChange({ ...field, key: e.target.value })}
|
|
/>
|
|
<span className="text-[10px] text-gray-500">
|
|
{t("settings.forms.builder.properties.fieldKeyHelp")}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.required}
|
|
onChange={(e) => onChange({ ...field, required: e.target.checked })}
|
|
/>
|
|
<span>{t("settings.forms.builder.properties.required")}</span>
|
|
</label>
|
|
{supportsPlaceholder ? (
|
|
<label className="block text-sm">
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.placeholder")}</span>
|
|
<input
|
|
className="border rounded px-2 py-1 w-full"
|
|
value={field.placeholder ?? ""}
|
|
onChange={(e) => onChange({ ...field, placeholder: e.target.value || undefined })}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
<label className="block text-sm">
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.helpText")}</span>
|
|
<input
|
|
className="border rounded px-2 py-1 w-full"
|
|
value={field.helpText ?? ""}
|
|
onChange={(e) => onChange({ ...field, helpText: e.target.value || undefined })}
|
|
/>
|
|
</label>
|
|
{supportsLength ? (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<label className="block text-sm">
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.minLength")}</span>
|
|
<NumericInput
|
|
fieldId={`${field.key}-min`}
|
|
value={field.minLength}
|
|
onChange={(next) => onChange({ ...field, minLength: next })}
|
|
/>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.maxLength")}</span>
|
|
<NumericInput
|
|
fieldId={`${field.key}-max`}
|
|
value={field.maxLength}
|
|
onChange={(next) => onChange({ ...field, maxLength: next })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
{supportsOptions ? (
|
|
<div>
|
|
<span className="block text-xs uppercase tracking-wide text-gray-500 mb-1">{t("settings.forms.builder.properties.options.title")}</span>
|
|
<OptionsEditor
|
|
options={field.options ?? []}
|
|
onChange={(next) => onChange({ ...field, options: next })}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</aside>
|
|
);
|
|
}
|