TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 09:52:42 +09:00
parent e726f43f7c
commit a5b432fb7b
167 changed files with 7397 additions and 663 deletions
@@ -14,6 +14,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const checkboxProps = block.props as FormCheckboxBlockProps;
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
@@ -35,6 +36,78 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={groupLabelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelDisplay: e.target.value,
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span></span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={checkboxProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={checkboxProps.optionLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
{groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
unitLabel="(px)"
value={typeof checkboxProps.labelGapPx === "number" ? checkboxProps.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-400"> </span>
<input
@@ -320,17 +393,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</div>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(checkboxProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
<span className="text-slate-500"> .</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
+20 -1
View File
@@ -245,6 +245,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
: labelText || fieldId;
const checked = (formProps.fieldIds ?? []).includes(fieldId);
const required = (formProps.requiredFieldIds ?? []).includes(fieldId);
return (
<label
@@ -266,7 +267,25 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any);
}}
/>
<span>{displayLabel}</span>
<span className="flex-1 truncate">{displayLabel}</span>
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
checked={required}
onChange={(e) => {
const current = formProps.requiredFieldIds ?? [];
const next = e.target.checked
? Array.from(new Set([...current, fieldId]))
: current.filter((id) => id !== fieldId);
updateBlock(selectedBlockId, {
requiredFieldIds: next,
} as any);
}}
/>
<span></span>
</label>
</label>
);
})}
@@ -14,6 +14,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
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">
@@ -45,6 +46,60 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={labelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelDisplay: e.target.value,
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
<option value="floating"> </option>
</select>
</label>
{labelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span></span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={inputProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
)}
{labelDisplay === "visible" && (inputProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
unitLabel="(px)"
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">
@@ -116,17 +171,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(inputProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
<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-200"> </h4>
@@ -219,42 +264,6 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
<option value="right"></option>
</select>
</label>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span></span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={inputProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
{(inputProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
unitLabel="(px)"
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)
}
/>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span></span>
<select
@@ -271,26 +280,24 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
<option value="fixed"> </option>
</select>
</label>
{(inputProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
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="필드 고정 너비"
unitLabel="(px)"
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="필드 가로 패딩 (px)"
unitLabel="(px)"
@@ -14,6 +14,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const radioProps = block.props as FormRadioBlockProps;
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
@@ -35,6 +36,78 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={groupLabelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelDisplay: e.target.value,
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={radioProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={radioProps.optionLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
{groupLabelDisplay === "visible" && (radioProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
unitLabel="(px)"
value={typeof radioProps.labelGapPx === "number" ? radioProps.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-400"> </span>
<input
@@ -320,17 +393,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</div>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(radioProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
<span className="text-slate-500"> .</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
@@ -14,6 +14,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
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">
@@ -35,6 +36,62 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
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-400">
<span></span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
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-400"> </span>
<input
@@ -136,17 +193,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
</div>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(selectProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
<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-200"> </h4>
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import "../../styles/editor.css";
export default function EditorLayout({ children }: { children: ReactNode }) {
// 에디터 전용 전역 스타일(editor.css)을 로드하기 위한 중첩 레이아웃.
// 실제 마크업 구조는 page.tsx 에서 정의하며, 여기서는 children 을 그대로 반환한다.
return children;
}
+184 -49
View File
@@ -184,6 +184,21 @@ function EditorPageInner() {
const slug = initialSlugFromQuery.trim();
if (!slug) return;
// 이미 해당 slug 에 대한 autosave 스냅샷이 있으면,
// 서버에서 프로젝트를 다시 불러와서 현재 로컬 상태를 덮어쓰지 않는다.
if (typeof window !== "undefined") {
try {
const key = `pb:autosave:${slug}`;
const raw = window.localStorage.getItem(key);
if (raw) {
setHasLoadedInitialProjectFromSlug(true);
return;
}
} catch {
// localStorage 접근 실패 시에는 서버 로드를 계속 시도한다.
}
}
let cancelled = false;
const loadFromServer = async () => {
@@ -218,7 +233,7 @@ function EditorPageInner() {
return () => {
cancelled = true;
};
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]);
useEffect(() => {
let cancelled = false;
@@ -707,6 +722,13 @@ function EditorPageInner() {
const rootBlocks = blocks.filter((block) => !block.sectionId);
const projectSlugRaw = projectConfig?.slug?.trim?.();
const projectSlug =
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
const previewHref = projectSlug
? `/preview?slug=${encodeURIComponent(projectSlug)}`
: "/preview";
useEffect(() => {
if (!authChecked) return;
if (typeof window === "undefined") return;
@@ -951,7 +973,7 @@ function EditorPageInner() {
<span> </span>
</Link>
<Link
href="/preview"
href={previewHref}
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
<Eye className="w-3 h-3" aria-hidden="true" />
@@ -1443,6 +1465,7 @@ function SortableEditorBlock({
{block.type === "formInput" && (() => {
const inputProps = block.props as FormInputBlockProps;
const inputType = inputProps.inputType ?? "text";
const fieldName = inputProps.formFieldName ?? block.id;
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
const inputTokens = computeFormInputEditorTokens(inputProps);
@@ -1452,47 +1475,100 @@ function SortableEditorBlock({
const isInline = labelLayout === "inline";
const inputAlignClass = inputTokens.inputAlignClass;
const labelDisplay = (inputProps as any).labelDisplay ?? "visible";
// 프리뷰/퍼블릭과 동일한 pb-input/pb-textarea 클래스를 사용해 높이/패딩을 통일한다.
const baseInputClass = `pb-input w-full text-xs outline-none ${inputAlignClass}`;
const baseTextareaClass = `pb-textarea w-full text-xs outline-none ${inputAlignClass}`;
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
return (
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
{labelDisplay === "visible" && (
inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
)
)}
{(() => {
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
const widthClass = inputTokens.widthClass;
if (labelDisplay === "floating") {
const placeholder = " ";
return (
<div className={`${widthClass} relative`}>
<span
data-testid="editor-form-input-floating-label"
className="absolute left-2 pointer-events-none"
style={{
top: "-0.3rem",
fontSize: "0.725rem",
color: "#e5e7eb",
backgroundColor: "#020617",
padding: "0 0.25rem",
}}
>
{inputProps.label}
</span>
{inputType === "textarea" ? (
<textarea
data-testid="form-input-field"
className={`${baseTextareaClass} pt-6`}
style={fieldStyle}
name={fieldName}
placeholder={placeholder}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
data-testid="form-input-field"
className={`${baseInputClass} pt-6`}
style={fieldStyle}
type={inputType === "email" ? "email" : "text"}
name={fieldName}
placeholder={placeholder}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
}
return (
<div
data-testid="form-input-field"
style={fieldStyle}
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
>
{inputType === "textarea" ? (
<textarea
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
type={inputType === "email" ? "email" : "text"}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
<div className={widthClass}>
{inputType === "textarea" ? (
<textarea
data-testid="form-input-field"
className={baseTextareaClass}
style={fieldStyle}
name={fieldName}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
data-testid="form-input-field"
className={baseInputClass}
style={fieldStyle}
type={inputType === "email" ? "email" : "text"}
name={fieldName}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
})()}
</div>
@@ -1500,6 +1576,7 @@ function SortableEditorBlock({
})()}
{block.type === "formSelect" && (() => {
const selectProps = block.props as FormSelectBlockProps;
const fieldName = selectProps.formFieldName ?? block.id;
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
? selectProps.options
: [
@@ -1523,12 +1600,12 @@ function SortableEditorBlock({
) : (
<span className="text-slate-200">{selectProps.label}</span>
)}
<div
style={fieldStyle}
className="w-full rounded border border-slate-700 bg-slate-950"
>
<div className="w-full">
<select
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
data-testid="form-select-field"
className="pb-select w-full text-xs outline-none"
style={fieldStyle}
name={fieldName}
aria-label={selectProps.label}
defaultValue={options[0]?.value}
>
@@ -1552,12 +1629,42 @@ function SortableEditorBlock({
];
const groupLabelMode = radioProps.groupLabelMode ?? "text";
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
const labelLayout = radioProps.labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
const fieldStyle = radioTokens.fieldStyle;
const optionLayout = radioProps.optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
const optionGapPx =
typeof radioProps.optionGapPx === "number" && radioProps.optionGapPx >= 0
? radioProps.optionGapPx
: undefined;
const gapStyle: CSSProperties =
optionGapPx !== undefined
? optionLayout === "inline"
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
: { rowGap: `${optionGapPx}px` }
: {};
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
const inlineGapPx = typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8;
const groupContainerStyle: CSSProperties = isInlineLayout
? { columnGap: `${inlineGapPx}px` }
: {};
const groupContainerClassName = isInlineLayout
? "flex flex-row items-center text-xs"
: "flex flex-col gap-1 text-xs";
return (
<div className="flex flex-col gap-1 text-xs">
<div className={groupContainerClassName} style={groupContainerStyle}>
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -1569,15 +1676,12 @@ function SortableEditorBlock({
) : (
<span className="text-slate-200">{radioProps.groupLabel}</span>
)}
<div
style={fieldStyle}
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
>
<div style={{ ...fieldStyle, ...gapStyle }} className={optionsLayoutClass}>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
<label key={opt.value} className="pb-form-option">
<input
type="radio"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={radioProps.formFieldName}
value={opt.value}
readOnly
@@ -1609,12 +1713,43 @@ function SortableEditorBlock({
];
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
const labelLayout = checkboxProps.labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
const fieldStyle = checkboxTokens.fieldStyle;
const optionLayout = checkboxProps.optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
const inlineGapPx = typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8;
const groupContainerStyle: CSSProperties = {
...fieldStyle,
...(isInlineLayout ? { columnGap: `${inlineGapPx}px` } : {}),
};
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
const optionGapPx =
typeof checkboxProps.optionGapPx === "number" && checkboxProps.optionGapPx >= 0
? checkboxProps.optionGapPx
: undefined;
const optionsStyle: CSSProperties =
optionGapPx !== undefined
? optionLayout === "inline"
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
: { rowGap: `${optionGapPx}px` }
: {};
const groupContainerClassName = isInlineLayout
? "flex flex-row items-center text-xs text-slate-200"
: "flex flex-col gap-1 text-xs text-slate-200";
return (
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
<div className={groupContainerClassName} style={groupContainerStyle}>
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -1626,12 +1761,12 @@ function SortableEditorBlock({
) : (
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
)}
<div className="flex flex-col gap-1">
<div className={optionsLayoutClass} style={optionsStyle}>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2">
<label key={opt.value} className="pb-form-option">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
name={checkboxProps.formFieldName}
value={opt.value}
readOnly
@@ -472,9 +472,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
}
return 0;
})()}
min={-2}
max={10}
step={0.1}
min={-32}
max={32}
step={1}
presets={[
{ id: "tighter", label: "아주 좁게", value: -1.5 },
{ id: "tight", label: "좁게", value: -0.5 },