436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import type { Block, FormInputBlockProps } from "@/features/editor/state/editorStore";
|
|
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
|
import { getEditorFormInputPanelMessages } from "@/features/i18n/messages/editorFormInputPanel";
|
|
|
|
interface FormInputPropertiesPanelProps {
|
|
block: Block;
|
|
selectedBlockId: string | null;
|
|
updateBlock: (id: string, partial: any) => void;
|
|
}
|
|
|
|
export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }: FormInputPropertiesPanelProps) {
|
|
const locale = useAppLocale();
|
|
const m = getEditorFormInputPanelMessages(locale);
|
|
if (!selectedBlockId || block.id !== selectedBlockId || block.type !== "formInput") return null;
|
|
|
|
const inputProps = block.props as FormInputBlockProps;
|
|
const labelDisplay = inputProps.labelDisplay ?? "visible";
|
|
|
|
return (
|
|
<div className="space-y-3 text-xs">
|
|
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</span>
|
|
<select
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.labelMode ?? "text"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
labelMode: e.target.value,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="text">{m.labelTypeOptionText}</option>
|
|
<option value="image">{m.labelTypeOptionImage}</option>
|
|
</select>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.label ?? ""}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
label: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.labelDisplayLabel}</span>
|
|
<select
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={labelDisplay}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
labelDisplay: e.target.value,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="visible">{m.labelDisplayOptionVisible}</option>
|
|
<option value="hidden">{m.labelDisplayOptionHidden}</option>
|
|
<option value="floating">{m.labelDisplayOptionFloating}</option>
|
|
</select>
|
|
</label>
|
|
{labelDisplay === "visible" && (
|
|
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
|
<span>{m.layoutLabel}</span>
|
|
<select
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.labelLayout ?? "stacked"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
labelLayout: e.target.value,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="stacked">{m.layoutOptionStacked}</option>
|
|
<option value="inline">{m.layoutOptionInline}</option>
|
|
</select>
|
|
</label>
|
|
)}
|
|
{labelDisplay === "visible" && (inputProps.labelLayout ?? "stacked") === "inline" && (
|
|
<NumericPropertyControl
|
|
label={m.labelGapLabel}
|
|
unitLabel={m.labelGapUnitLabel}
|
|
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
|
min={0}
|
|
max={80}
|
|
step={2}
|
|
presets={[
|
|
{ id: "tight", label: "타이트", value: 4 },
|
|
{ id: "normal", label: "보통", value: 8 },
|
|
{ id: "relaxed", label: "느슨", value: 12 },
|
|
{ id: "extra", label: "넓게", value: 16 },
|
|
]}
|
|
onChangeValue={(v) =>
|
|
updateBlock(selectedBlockId, {
|
|
labelGapPx: v,
|
|
} as any)
|
|
}
|
|
/>
|
|
)}
|
|
{inputProps.labelMode === "image" && (
|
|
<>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.labelImageUrlLabel}</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.labelImageUrl ?? ""}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
labelImageUrl: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.labelImageAltLabel}</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.labelImageAlt ?? ""}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
labelImageAlt: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.formFieldName ?? ""}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
formFieldName: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.fieldTypeLabel}</span>
|
|
<select
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label={m.fieldTypeAria}
|
|
value={inputProps.inputType ?? "text"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
inputType: e.target.value,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="text">{m.fieldTypeOptionText}</option>
|
|
<option value="email">{m.fieldTypeOptionEmail}</option>
|
|
<option value="textarea">{m.fieldTypeOptionTextarea}</option>
|
|
</select>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.placeholderLabel}</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label={m.placeholderAria}
|
|
value={inputProps.placeholder ?? ""}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
placeholder: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="inline-flex items-center gap-2">
|
|
<span className="text-slate-500 dark:text-slate-400">{m.requiredNoticeText}</span>
|
|
</label>
|
|
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
|
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
|
|
<NumericPropertyControl
|
|
label={m.textSizeLabel}
|
|
unitLabel={m.textSizeUnitLabel}
|
|
value={(() => {
|
|
const raw = inputProps.fontSizeCustom ?? "";
|
|
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 14;
|
|
})()}
|
|
min={8}
|
|
max={40}
|
|
step={1}
|
|
presets={[
|
|
{ id: "xs", label: "XS", value: 12 },
|
|
{ id: "sm", label: "S", value: 14 },
|
|
{ id: "md", label: "M", value: 16 },
|
|
{ id: "lg", label: "L", value: 18 },
|
|
{ id: "xl", label: "XL", value: 20 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
fontSizeCustom: `${v}px`,
|
|
} as any);
|
|
}}
|
|
/>
|
|
{/* 줄간격 px 입력을 노출하여 preview em 변환의 근거를 제공 */}
|
|
<NumericPropertyControl
|
|
label={m.lineHeightLabel}
|
|
unitLabel={m.lineHeightUnitLabel}
|
|
value={(() => {
|
|
const raw = inputProps.lineHeightCustom ?? "";
|
|
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 20;
|
|
})()}
|
|
min={8}
|
|
max={60}
|
|
step={1}
|
|
presets={[
|
|
{ id: "tight", label: "타이트", value: 18 },
|
|
{ id: "normal", label: "보통", value: 24 },
|
|
{ id: "loose", label: "루즈", value: 32 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
lineHeightCustom: `${v}px`,
|
|
} as any);
|
|
}}
|
|
/>
|
|
{/* 자간 px 입력으로 폼 입력 타이포를 세밀하게 조정 */}
|
|
<NumericPropertyControl
|
|
label={m.letterSpacingLabel}
|
|
unitLabel={m.letterSpacingUnitLabel}
|
|
value={(() => {
|
|
const raw = inputProps.letterSpacingCustom ?? "";
|
|
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 0;
|
|
})()}
|
|
min={-10}
|
|
max={20}
|
|
step={0.5}
|
|
presets={[
|
|
{ id: "tight", label: "좁게", value: -1 },
|
|
{ id: "normal", label: "보통", value: 0 },
|
|
{ id: "wide", label: "넓게", value: 2 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
letterSpacingCustom: `${v}px`,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
|
<span>{m.textAlignLabel}</span>
|
|
<select
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={inputProps.align ?? "left"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
align: e.target.value,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="left">{m.textAlignOptionLeft}</option>
|
|
<option value="center">{m.textAlignOptionCenter}</option>
|
|
<option value="right">{m.textAlignOptionRight}</option>
|
|
</select>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
|
<span>{m.widthModeLabel}</span>
|
|
<select
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label={m.widthModeAria}
|
|
value={inputProps.widthMode ?? "full"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
widthMode: e.target.value,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="auto">{m.widthModeOptionAuto}</option>
|
|
<option value="full">{m.widthModeOptionFull}</option>
|
|
<option value="fixed">{m.widthModeOptionFixed}</option>
|
|
</select>
|
|
</label>
|
|
<NumericPropertyControl
|
|
label={m.fixedWidthLabel}
|
|
unitLabel={m.fixedWidthUnitLabel}
|
|
value={inputProps.widthPx ?? 240}
|
|
min={80}
|
|
max={800}
|
|
step={10}
|
|
presets={[
|
|
{ id: "sm", label: "작게", value: 200 },
|
|
{ id: "md", label: "보통", value: 280 },
|
|
{ id: "lg", label: "넓게", value: 360 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
widthPx: v,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<NumericPropertyControl
|
|
label={m.paddingXLabel}
|
|
unitLabel={m.paddingXUnitLabel}
|
|
value={typeof inputProps.paddingX === "number" ? inputProps.paddingX : 12}
|
|
min={0}
|
|
max={60}
|
|
step={2}
|
|
presets={[
|
|
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
{ id: "sm", label: "얇게", value: 10 },
|
|
{ id: "md", label: "보통", value: 12 },
|
|
{ id: "lg", label: "넓게", value: 16 },
|
|
{ id: "xl", label: "아주 넓게", value: 20 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
paddingX: v,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<NumericPropertyControl
|
|
label={m.paddingYLabel}
|
|
unitLabel={m.paddingYUnitLabel}
|
|
value={typeof inputProps.paddingY === "number" ? inputProps.paddingY : 10}
|
|
min={0}
|
|
max={40}
|
|
step={1}
|
|
presets={[
|
|
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
{ id: "sm", label: "얇게", value: 8 },
|
|
{ id: "md", label: "보통", value: 10 },
|
|
{ id: "lg", label: "넓게", value: 12 },
|
|
{ id: "xl", label: "아주 넓게", value: 16 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
paddingY: v,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<ColorPickerField
|
|
label={m.textColorLabel}
|
|
ariaLabelColorInput={m.textColorPickerAria}
|
|
ariaLabelHexInput={m.textColorHexAria}
|
|
value={
|
|
inputProps.textColorCustom && inputProps.textColorCustom.trim() !== ""
|
|
? inputProps.textColorCustom
|
|
: ""
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
textColorCustom: hex,
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, {
|
|
textColorCustom: item.color,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<ColorPickerField
|
|
label={m.fillColorLabel}
|
|
ariaLabelColorInput={m.fillColorPickerAria}
|
|
ariaLabelHexInput={m.fillColorHexAria}
|
|
value={
|
|
inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== ""
|
|
? inputProps.fillColorCustom
|
|
: ""
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
fillColorCustom: hex,
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, {
|
|
fillColorCustom: item.color,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<ColorPickerField
|
|
label={m.strokeColorLabel}
|
|
ariaLabelColorInput={m.strokeColorPickerAria}
|
|
ariaLabelHexInput={m.strokeColorHexAria}
|
|
value={
|
|
inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== ""
|
|
? inputProps.strokeColorCustom
|
|
: ""
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
strokeColorCustom: hex,
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, {
|
|
strokeColorCustom: item.color,
|
|
} as any);
|
|
}}
|
|
/>
|
|
<NumericPropertyControl
|
|
label={m.borderRadiusLabel}
|
|
value={(() => {
|
|
const r = inputProps.borderRadius ?? "md";
|
|
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
|
})()}
|
|
min={0}
|
|
max={8}
|
|
step={1}
|
|
presets={[
|
|
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
|
|
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
|
|
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
|
|
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
|
|
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
const next = v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
|
updateBlock(selectedBlockId, {
|
|
borderRadius: next,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|