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

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>
);
}