440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import type { Block, FormSelectBlockProps } from "@/features/editor/state/editorStore";
|
||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||
|
||
interface FormSelectPropertiesPanelProps {
|
||
block: Block;
|
||
selectedBlockId: string | null;
|
||
updateBlock: (id: string, partial: any) => void;
|
||
}
|
||
|
||
export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock }: FormSelectPropertiesPanelProps) {
|
||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
||
|
||
const selectProps = block.props as FormSelectBlockProps;
|
||
const labelDisplay = selectProps.labelDisplay ?? "visible";
|
||
|
||
return (
|
||
<div className="space-y-3 text-xs">
|
||
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">셀렉트 필드</h3>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">라벨 타입</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={selectProps.labelMode ?? "text"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
labelMode: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="text">텍스트</option>
|
||
<option value="image">이미지</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">라벨 표시 방식</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">표시 (기본)</option>
|
||
<option value="hidden">숨김</option>
|
||
</select>
|
||
</label>
|
||
|
||
{labelDisplay === "visible" && (
|
||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||
<span>레이아웃</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={selectProps.labelLayout ?? "stacked"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
labelLayout: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="stacked">세로 (기본)</option>
|
||
<option value="inline">가로 (인라인)</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
{labelDisplay === "visible" && (selectProps.labelLayout ?? "stacked") === "inline" && (
|
||
<NumericPropertyControl
|
||
label="라벨/필드 간격 (px)"
|
||
unitLabel="(px)"
|
||
value={typeof selectProps.labelGapPx === "number" ? selectProps.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)
|
||
}
|
||
/>
|
||
)}
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">필드 라벨</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={selectProps.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">전송 키</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={selectProps.formFieldName ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
formFieldName: e.target.value,
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-slate-500 dark:text-slate-400">옵션</span>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||
onClick={() => {
|
||
const next = Array.isArray((selectProps as any).options)
|
||
? [...(selectProps as any).options]
|
||
: [];
|
||
next.push({
|
||
label: "새 옵션",
|
||
value: `option_${next.length + 1}`,
|
||
});
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
>
|
||
옵션 추가
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{(((selectProps as any).options ?? []) as any[]).map((opt, index) => (
|
||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
|
||
<input
|
||
className="flex-1 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"
|
||
placeholder="라벨"
|
||
value={opt.label ?? ""}
|
||
onChange={(e) => {
|
||
const next = [...(((selectProps as any).options ?? []) as any[])];
|
||
next[index] = {
|
||
...next[index],
|
||
label: e.target.value,
|
||
};
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<input
|
||
className="flex-1 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"
|
||
placeholder="값(value)"
|
||
value={opt.value ?? ""}
|
||
onChange={(e) => {
|
||
const next = [...(((selectProps as any).options ?? []) as any[])];
|
||
next[index] = {
|
||
...next[index],
|
||
value: e.target.value,
|
||
};
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="h-7 w-7 rounded border border-slate-300 bg-white text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
|
||
onClick={() => {
|
||
const next = (((selectProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="inline-flex items-center gap-2">
|
||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</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">필드 스타일</h4>
|
||
<NumericPropertyControl
|
||
label="셀렉트 텍스트 크기 (px)"
|
||
unitLabel="(px)"
|
||
value={(() => {
|
||
const raw = selectProps.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="셀렉트 줄간격 (px)"
|
||
unitLabel="(px)"
|
||
value={(() => {
|
||
const raw = selectProps.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 입력을 제공하여 TDD 시나리오와 동기화한다 */}
|
||
<NumericPropertyControl
|
||
label="셀렉트 자간 (px)"
|
||
unitLabel="(px)"
|
||
value={(() => {
|
||
const raw = selectProps.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>필드 너비</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={selectProps.widthMode ?? "full"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
widthMode: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="auto">자동</option>
|
||
<option value="full">전체 폭</option>
|
||
<option value="fixed">고정 값</option>
|
||
</select>
|
||
</label>
|
||
{(selectProps.widthMode ?? "full") === "fixed" && (
|
||
<NumericPropertyControl
|
||
label="필드 고정 너비"
|
||
unitLabel="(px)"
|
||
value={selectProps.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="셀렉트 가로 패딩 (px)"
|
||
unitLabel="(px)"
|
||
value={typeof selectProps.paddingX === "number" ? selectProps.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="셀렉트 세로 패딩 (px)"
|
||
unitLabel="(px)"
|
||
value={typeof selectProps.paddingY === "number" ? selectProps.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="필드 텍스트 색상"
|
||
ariaLabelColorInput="셀렉트 텍스트 색상 피커"
|
||
ariaLabelHexInput="셀렉트 텍스트 색상 HEX"
|
||
value={
|
||
selectProps.textColorCustom && selectProps.textColorCustom.trim() !== ""
|
||
? selectProps.textColorCustom
|
||
: ""
|
||
}
|
||
onChange={(hex) => {
|
||
updateBlock(selectedBlockId, {
|
||
textColorCustom: hex,
|
||
} as any);
|
||
}}
|
||
palette={TEXT_COLOR_PALETTE}
|
||
onPaletteSelect={(item) => {
|
||
updateBlock(selectedBlockId, {
|
||
textColorCustom: item.color,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<ColorPickerField
|
||
label="필드 채움 색상"
|
||
ariaLabelColorInput="셀렉트 채움 색상 피커"
|
||
ariaLabelHexInput="셀렉트 채움 색상 HEX"
|
||
value={
|
||
selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== ""
|
||
? selectProps.fillColorCustom
|
||
: ""
|
||
}
|
||
onChange={(hex) => {
|
||
updateBlock(selectedBlockId, {
|
||
fillColorCustom: hex,
|
||
} as any);
|
||
}}
|
||
palette={TEXT_COLOR_PALETTE}
|
||
onPaletteSelect={(item) => {
|
||
updateBlock(selectedBlockId, {
|
||
fillColorCustom: item.color,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<ColorPickerField
|
||
label="필드 테두리 색상"
|
||
ariaLabelColorInput="셀렉트 테두리 색상 피커"
|
||
ariaLabelHexInput="셀렉트 테두리 색상 HEX"
|
||
value={
|
||
selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== ""
|
||
? selectProps.strokeColorCustom
|
||
: ""
|
||
}
|
||
onChange={(hex) => {
|
||
updateBlock(selectedBlockId, {
|
||
strokeColorCustom: hex,
|
||
} as any);
|
||
}}
|
||
palette={TEXT_COLOR_PALETTE}
|
||
onPaletteSelect={(item) => {
|
||
updateBlock(selectedBlockId, {
|
||
strokeColorCustom: item.color,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<NumericPropertyControl
|
||
label="필드 모서리 둥글기"
|
||
value={(() => {
|
||
const r = selectProps.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: "없음", value: 0 },
|
||
{ id: "sm", label: "작게", value: 2 },
|
||
{ id: "md", label: "보통", value: 4 },
|
||
{ id: "lg", label: "크게", value: 6 },
|
||
{ id: "full", label: "완전 둥글게", 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>
|
||
);
|
||
}
|