665 lines
28 KiB
TypeScript
665 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import type { Block, FormCheckboxBlockProps } 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 { getEditorFormCheckboxPanelMessages } from "@/features/i18n/messages/editorFormCheckboxPanel";
|
||
|
||
interface FormCheckboxPropertiesPanelProps {
|
||
block: Block;
|
||
selectedBlockId: string | null;
|
||
updateBlock: (id: string, partial: any) => void;
|
||
}
|
||
|
||
export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBlock }: FormCheckboxPropertiesPanelProps) {
|
||
const locale = useAppLocale();
|
||
const m = getEditorFormCheckboxPanelMessages(locale);
|
||
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">
|
||
<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.groupTitleTypeLabel}</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={checkboxProps.groupLabelMode ?? "text"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
groupLabelMode: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="text">{m.groupTitleTypeOptionText}</option>
|
||
<option value="image">{m.groupTitleTypeOptionImage}</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleDisplayLabel}</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={groupLabelDisplay}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
groupLabelDisplay: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="visible">{m.groupTitleDisplayOptionVisible}</option>
|
||
<option value="hidden">{m.groupTitleDisplayOptionHidden}</option>
|
||
</select>
|
||
</label>
|
||
|
||
{groupLabelDisplay === "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={checkboxProps.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>
|
||
)}
|
||
|
||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||
<span>{m.optionLayoutLabel}</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={checkboxProps.optionLayout ?? "stacked"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
optionLayout: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="stacked">{m.layoutOptionStacked}</option>
|
||
<option value="inline">{m.layoutOptionInline}</option>
|
||
</select>
|
||
</label>
|
||
|
||
{groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && (
|
||
<NumericPropertyControl
|
||
label={m.labelGapLabel}
|
||
unitLabel="(px)"
|
||
value={typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8}
|
||
min={0}
|
||
max={80}
|
||
step={2}
|
||
presets={[
|
||
{ id: "tight", label: "Tight", value: 4 },
|
||
{ id: "normal", label: "Normal", value: 8 },
|
||
{ id: "relaxed", label: "Relaxed", value: 12 },
|
||
{ id: "extra", label: "Extra", 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">{m.groupTitleLabel}</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={checkboxProps.groupLabel ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
groupLabel: e.target.value,
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
{checkboxProps.groupLabelMode === "image" && (() => {
|
||
const source: "url" | "upload" =
|
||
checkboxProps.groupLabelImageSource ??
|
||
(checkboxProps.groupLabelImageUrl && checkboxProps.groupLabelImageUrl.startsWith("/api/image/")
|
||
? "upload"
|
||
: "url");
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-400">{m.groupTitleImageSourceLabel}</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={source}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
groupLabelImageSource: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="url">{m.groupTitleImageSourceOptionUrl}</option>
|
||
<option value="upload">{m.groupTitleImageSourceOptionUpload}</option>
|
||
</select>
|
||
</label>
|
||
|
||
{source === "url" && (
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-400">{m.groupTitleImageUrlLabel}</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={checkboxProps.groupLabelImageUrl ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
groupLabelImageUrl: e.target.value,
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{source === "upload" && (
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-400">{m.groupTitleImageUploadLabel}</span>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||
aria-label={m.groupTitleImageUploadAria}
|
||
onChange={async (event) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetch("/api/image", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.error("체크박스 그룹 라벨 이미지 업로드 실패", await response.text());
|
||
return;
|
||
}
|
||
|
||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||
|
||
updateBlock(selectedBlockId, {
|
||
groupLabelImageUrl: servedUrl,
|
||
groupLabelImageSource: "upload",
|
||
} as any);
|
||
} catch (error) {
|
||
console.error("체크박스 그룹 라벨 이미지 업로드 중 오류", error);
|
||
} finally {
|
||
event.target.value = "";
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<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={checkboxProps.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">{m.optionsLabel}</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((checkboxProps as any).options)
|
||
? [...(checkboxProps as any).options]
|
||
: [];
|
||
next.push({
|
||
label: "New option",
|
||
value: `option_${next.length + 1}`,
|
||
});
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
>
|
||
{m.addOptionButtonLabel}
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</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={(() => {
|
||
if (checkboxProps.optionImageSource) return checkboxProps.optionImageSource;
|
||
const first = (((checkboxProps as any).options ?? []) as any[])[0];
|
||
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
|
||
return "url";
|
||
})()}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
optionImageSource: e.target.value,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="url">{m.optionImageSourceOptionUrl}</option>
|
||
<option value="upload">{m.optionImageSourceOptionUpload}</option>
|
||
</select>
|
||
</label>
|
||
|
||
{(((checkboxProps 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={m.optionLabelPlaceholder}
|
||
value={opt.label ?? ""}
|
||
onChange={(e) => {
|
||
const next = [...(((checkboxProps 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={m.optionValuePlaceholder}
|
||
value={opt.value ?? ""}
|
||
onChange={(e) => {
|
||
const next = [...(((checkboxProps 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 = (((checkboxProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{(() => {
|
||
const source: "url" | "upload" =
|
||
checkboxProps.optionImageSource ??
|
||
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
|
||
|
||
return (
|
||
<>
|
||
{source === "url" && (
|
||
<input
|
||
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"
|
||
placeholder={m.optionImageUrlPlaceholder}
|
||
value={opt.labelImageUrl ?? ""}
|
||
onChange={(e) => {
|
||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||
next[index] = {
|
||
...next[index],
|
||
labelImageUrl: e.target.value,
|
||
};
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
} as any);
|
||
}}
|
||
/>
|
||
)}
|
||
{source === "upload" && (
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.optionImageUploadLabel}</span>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||
aria-label={m.optionImageUploadAria}
|
||
onChange={async (event) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetch("/api/image", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.error("체크박스 옵션 라벨 이미지 업로드 실패", await response.text());
|
||
return;
|
||
}
|
||
|
||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||
|
||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||
next[index] = {
|
||
...next[index],
|
||
labelImageUrl: servedUrl,
|
||
};
|
||
|
||
updateBlock(selectedBlockId, {
|
||
options: next,
|
||
optionImageSource: "upload",
|
||
} as any);
|
||
} catch (error) {
|
||
console.error("체크박스 옵션 라벨 이미지 업로드 중 오류", error);
|
||
} finally {
|
||
event.target.value = "";
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="inline-flex items-center gap-2">
|
||
<span className="text-slate-500">{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>
|
||
{/* px 기반 체크박스 타이포 입력값을 노출하여 em 스케일 변환을 위한 근거를 남긴다 */}
|
||
<NumericPropertyControl
|
||
label={m.textSizeLabel}
|
||
unitLabel={m.textSizeUnitLabel}
|
||
value={(() => {
|
||
const raw = checkboxProps.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 변환과 TDD 케이스를 연동한다 */}
|
||
<NumericPropertyControl
|
||
label={m.lineHeightLabel}
|
||
unitLabel={m.lineHeightUnitLabel}
|
||
value={(() => {
|
||
const raw = checkboxProps.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: "Tight", value: 18 },
|
||
{ id: "normal", label: "Normal", value: 24 },
|
||
{ id: "loose", label: "Loose", value: 32 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
lineHeightCustom: `${v}px`,
|
||
} as any)
|
||
}
|
||
/>
|
||
{/* 자간 px 입력을 통한 폼 체크박스 미세 조정값을 노출 */}
|
||
<NumericPropertyControl
|
||
label={m.letterSpacingLabel}
|
||
unitLabel={m.letterSpacingUnitLabel}
|
||
value={(() => {
|
||
const raw = checkboxProps.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: "Tight", value: -1 },
|
||
{ id: "normal", label: "Normal", value: 0 },
|
||
{ id: "wide", label: "Wide", value: 2 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
letterSpacingCustom: `${v}px`,
|
||
} as any)
|
||
}
|
||
/>
|
||
<label className="flex flex-col gap-1 text-[11px] 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"
|
||
value={checkboxProps.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>
|
||
{(checkboxProps.widthMode ?? "full") === "fixed" && (
|
||
<NumericPropertyControl
|
||
label={m.fixedWidthLabel}
|
||
unitLabel={m.fixedWidthUnitLabel}
|
||
value={checkboxProps.widthPx ?? 240}
|
||
min={80}
|
||
max={800}
|
||
step={10}
|
||
presets={[
|
||
{ id: "sm", label: "Small", value: 200 },
|
||
{ id: "md", label: "Medium", value: 280 },
|
||
{ id: "lg", label: "Large", value: 360 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
widthPx: v,
|
||
} as any)
|
||
}
|
||
/>
|
||
)}
|
||
<NumericPropertyControl
|
||
label={m.paddingXLabel}
|
||
unitLabel={m.paddingXUnitLabel}
|
||
value={typeof checkboxProps.paddingX === "number" ? checkboxProps.paddingX : 12}
|
||
min={0}
|
||
max={60}
|
||
step={2}
|
||
presets={[
|
||
{ id: "xs", label: "Extra thin", value: 6 },
|
||
{ id: "sm", label: "Thin", value: 10 },
|
||
{ id: "md", label: "Normal", value: 12 },
|
||
{ id: "lg", label: "Wide", value: 16 },
|
||
{ id: "xl", label: "Extra wide", value: 20 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
paddingX: v,
|
||
} as any)
|
||
}
|
||
/>
|
||
<NumericPropertyControl
|
||
label={m.paddingYLabel}
|
||
unitLabel={m.paddingYUnitLabel}
|
||
value={typeof checkboxProps.paddingY === "number" ? checkboxProps.paddingY : 10}
|
||
min={0}
|
||
max={40}
|
||
step={1}
|
||
presets={[
|
||
{ id: "xs", label: "Extra thin", value: 6 },
|
||
{ id: "sm", label: "Thin", value: 8 },
|
||
{ id: "md", label: "Normal", value: 10 },
|
||
{ id: "lg", label: "Wide", value: 12 },
|
||
{ id: "xl", label: "Extra wide", value: 16 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
paddingY: v,
|
||
} as any)
|
||
}
|
||
/>
|
||
<NumericPropertyControl
|
||
label={m.optionGapLabel}
|
||
unitLabel={m.optionGapUnitLabel}
|
||
value={typeof checkboxProps.optionGapPx === "number" ? checkboxProps.optionGapPx : 4}
|
||
min={0}
|
||
max={40}
|
||
step={1}
|
||
presets={[
|
||
{ id: "tight", label: "Tight", value: 2 },
|
||
{ id: "normal", label: "Normal", value: 4 },
|
||
{ id: "relaxed", label: "Relaxed", value: 8 },
|
||
{ id: "extra", label: "Extra", value: 12 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
optionGapPx: v,
|
||
} as any)
|
||
}
|
||
/>
|
||
<ColorPickerField
|
||
label={m.textColorLabel}
|
||
ariaLabelColorInput={m.textColorPickerAria}
|
||
ariaLabelHexInput={m.textColorHexAria}
|
||
value={
|
||
checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== ""
|
||
? checkboxProps.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={
|
||
checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== ""
|
||
? checkboxProps.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={
|
||
checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== ""
|
||
? checkboxProps.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 = checkboxProps.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>
|
||
);
|
||
}
|