343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|
import { ButtonConfig, ButtonVariant } from "./types";
|
|
|
|
/**
|
|
* Button — 통합 단일 버튼 컴포넌트
|
|
*
|
|
* 흡수 대상:
|
|
* - v2-button-primary (base)
|
|
* - button-primary (legacy)
|
|
* - related-data-buttons (legacy, 버튼 그룹은 여러 button 배치로 대체)
|
|
*
|
|
* 변형:
|
|
* - variant: primary / secondary / default / destructive / outline / ghost
|
|
* - actionType: 12종 + custom
|
|
* - size: sm / md / lg
|
|
*/
|
|
|
|
export interface ButtonComponentProps extends ComponentRendererProps {
|
|
config?: ButtonConfig;
|
|
}
|
|
|
|
// variant 별 기본 스타일 (config override 가능)
|
|
const VARIANT_PRESETS: Record<
|
|
ButtonVariant,
|
|
{ background: string; color: string; border: string }
|
|
> = {
|
|
primary: {
|
|
background: "hsl(var(--primary))",
|
|
color: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--primary))",
|
|
},
|
|
secondary: {
|
|
background: "hsl(var(--muted-foreground))",
|
|
color: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--muted-foreground))",
|
|
},
|
|
default: {
|
|
background: "hsl(var(--border))",
|
|
color: "hsl(var(--foreground))",
|
|
border: "1px solid hsl(var(--border))",
|
|
},
|
|
destructive: {
|
|
background: "hsl(var(--destructive))",
|
|
color: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--destructive))",
|
|
},
|
|
outline: {
|
|
background: "transparent",
|
|
color: "hsl(var(--foreground))",
|
|
border: "1px solid hsl(var(--border))",
|
|
},
|
|
ghost: {
|
|
background: "transparent",
|
|
color: "hsl(var(--foreground))",
|
|
border: "1px solid transparent",
|
|
},
|
|
};
|
|
|
|
const SIZE_PRESETS: Record<
|
|
NonNullable<ButtonConfig["size"]>,
|
|
{ padding: string; fontSize: string; height: string }
|
|
> = {
|
|
sm: { padding: "4px 10px", fontSize: "11px", height: "26px" },
|
|
md: { padding: "6px 14px", fontSize: "13px", height: "32px" },
|
|
lg: { padding: "8px 18px", fontSize: "15px", height: "40px" },
|
|
};
|
|
|
|
export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config,
|
|
className,
|
|
style,
|
|
...props
|
|
}) => {
|
|
// ─── 4경로 머지 (Phase A-2 에서 확립된 표준 패턴) ────────────────────────
|
|
// ★ 주의: props.size 는 component 의 visual size {width, height} 객체이므로
|
|
// ButtonConfig.size (sm/md/lg 문자열) 와 충돌. fromProps 에 size 는 넣지
|
|
// 않고, 문자열 여부 체크 후 사용.
|
|
const fromProps: Partial<ButtonConfig> = {};
|
|
const p = props as any;
|
|
if (p.text !== undefined) fromProps.text = p.text;
|
|
if (typeof p.variant === "string") fromProps.variant = p.variant;
|
|
if (typeof p.actionType === "string") fromProps.actionType = p.actionType;
|
|
if (p.confirm !== undefined) fromProps.confirm = p.confirm;
|
|
if (typeof p.disabled === "boolean") fromProps.disabled = p.disabled;
|
|
if (typeof p.icon === "string") fromProps.icon = p.icon;
|
|
if (typeof p.iconPosition === "string") fromProps.iconPosition = p.iconPosition;
|
|
if (typeof p.backgroundColor === "string")
|
|
fromProps.backgroundColor = p.backgroundColor;
|
|
if (typeof p.textColor === "string") fromProps.textColor = p.textColor;
|
|
if (typeof p.borderRadius === "string") fromProps.borderRadius = p.borderRadius;
|
|
|
|
// 2) 4경로 머지 (마지막이 최우선)
|
|
const componentConfig = {
|
|
...config,
|
|
...((component as any).config ?? {}),
|
|
...((component as any).componentConfig ?? {}),
|
|
...fromProps,
|
|
} as ButtonConfig;
|
|
|
|
// ★ text/icon 은 문자열이어야 함. object 가 들어오면 React child 에러 → 방어
|
|
const rawText = componentConfig.text;
|
|
const text: string =
|
|
typeof rawText === "string" || typeof rawText === "number"
|
|
? String(rawText)
|
|
: "버튼";
|
|
const variant: ButtonVariant = componentConfig.variant ?? "primary";
|
|
// ★ componentConfig.size 는 가끔 {width, height} 객체가 섞여 올 수 있어 방어
|
|
const sizeKey: NonNullable<ButtonConfig["size"]> =
|
|
typeof componentConfig.size === "string" &&
|
|
["sm", "md", "lg"].includes(componentConfig.size)
|
|
? (componentConfig.size as NonNullable<ButtonConfig["size"]>)
|
|
: "md";
|
|
const disabled = componentConfig.disabled ?? false;
|
|
const rawIcon = componentConfig.icon;
|
|
const icon: string | null = typeof rawIcon === "string" ? rawIcon : null;
|
|
const iconPosition = componentConfig.iconPosition ?? "left";
|
|
|
|
const variantStyle = VARIANT_PRESETS[variant] ?? VARIANT_PRESETS.primary;
|
|
const sizeStyle = SIZE_PRESETS[sizeKey] ?? SIZE_PRESETS.md;
|
|
|
|
// 디자인 모드에서는 wrapper 박스 크기(리사이즈한 값)를 그대로 채워야
|
|
// 디자이너 캔버스에서 박스 크기 = 시각 크기가 된다.
|
|
//
|
|
// 런타임에서는 두 경로가 있다:
|
|
// (A) component.size.{width,height} 가 유효한 px 로 넘어온 경우 (line 엔진)
|
|
// → design px (예: 120x40) 를 root 에 직접 적용. 버튼이 wrapper cell
|
|
// 전체로 stretch 되지 않고 디자이너가 준 크기 그대로 렌더.
|
|
// (B) 그 외 (기존 band 경로 등)
|
|
// → content size (sizePreset 기반). 기존 회귀 방지용 기본 동작.
|
|
const fillWrapper = isDesignMode === true;
|
|
const rawSize = (component as any)?.size;
|
|
const sizeW =
|
|
typeof rawSize?.width === "number" && rawSize.width > 0 ? rawSize.width : 0;
|
|
const sizeH =
|
|
typeof rawSize?.height === "number" && rawSize.height > 0 ? rawSize.height : 0;
|
|
const useDesignPx = !fillWrapper && sizeW > 0 && sizeH > 0;
|
|
|
|
const buttonStyle: React.CSSProperties = {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: "6px",
|
|
padding: sizeStyle.padding,
|
|
fontSize: sizeStyle.fontSize,
|
|
minHeight: sizeStyle.height,
|
|
fontWeight: 600,
|
|
border: variantStyle.border,
|
|
borderRadius: componentConfig.borderRadius ?? "6px",
|
|
background: componentConfig.backgroundColor ?? variantStyle.background,
|
|
color: componentConfig.textColor ?? variantStyle.color,
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.5 : 1,
|
|
transition: "opacity 0.1s, transform 0.05s",
|
|
userSelect: "none",
|
|
whiteSpace: "nowrap",
|
|
...(fillWrapper ? { width: "100%", height: "100%" } : {}),
|
|
...(component as any).style,
|
|
...style,
|
|
// design px 는 외부 style 보다 뒤에 spread 해서 최종 승자가 되게 한다
|
|
...(useDesignPx
|
|
? {
|
|
width: `${sizeW}px`,
|
|
height: `${sizeH}px`,
|
|
minHeight: `${sizeH}px`,
|
|
flex: "0 0 auto",
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
if (isDesignMode && isSelected) {
|
|
buttonStyle.outline = "2px solid hsl(var(--primary))";
|
|
buttonStyle.outlineOffset = "2px";
|
|
}
|
|
|
|
// 템플릿 컨텍스트가 내려주는 액션 콜백 — actionType 에 따라 해당 콜백 호출
|
|
const p2 = props as any;
|
|
const ctxOnAdd: (() => void) | undefined =
|
|
typeof p2.onAdd === "function" ? p2.onAdd : undefined;
|
|
const ctxOnEdit: (() => void) | undefined =
|
|
typeof p2.onEdit === "function" ? p2.onEdit : undefined;
|
|
const ctxOnDelete: (() => void) | undefined =
|
|
typeof p2.onDelete === "function" ? p2.onDelete : undefined;
|
|
const ctxOnFormSubmit: (() => void) | undefined =
|
|
typeof p2.onFormSubmit === "function" ? p2.onFormSubmit : undefined;
|
|
const ctxOnFormCancel: (() => void) | undefined =
|
|
typeof p2.onFormCancel === "function" ? p2.onFormCancel : undefined;
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (disabled) return;
|
|
if (isDesignMode) {
|
|
onClick?.();
|
|
return;
|
|
}
|
|
if (componentConfig.confirm && !window.confirm(componentConfig.confirm)) {
|
|
return;
|
|
}
|
|
|
|
// actionType 기반 분기 — 매칭되는 게 있으면 그걸 호출하고,
|
|
// 아니면 기존 onClick 폴백 (빌더/커스텀 경로)
|
|
const at = componentConfig.actionType;
|
|
switch (at) {
|
|
case "add":
|
|
if (ctxOnAdd) return ctxOnAdd();
|
|
break;
|
|
case "edit":
|
|
if (ctxOnEdit) return ctxOnEdit();
|
|
break;
|
|
case "delete":
|
|
if (ctxOnDelete) return ctxOnDelete();
|
|
break;
|
|
case "save":
|
|
case "submit":
|
|
if (ctxOnFormSubmit) return ctxOnFormSubmit();
|
|
break;
|
|
case "cancel":
|
|
case "close":
|
|
if (ctxOnFormCancel) return ctxOnFormCancel();
|
|
break;
|
|
// search / reset / navigate / popup / approval / custom 은 다음 단계
|
|
default:
|
|
break;
|
|
}
|
|
onClick?.();
|
|
};
|
|
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
const {
|
|
selectedScreen: _1,
|
|
onZoneComponentDrop: _2,
|
|
onZoneClick: _3,
|
|
componentConfig: _4,
|
|
component: _5,
|
|
isSelected: _6,
|
|
onClick: _7,
|
|
onDragStart: _8,
|
|
onDragEnd: _9,
|
|
size: _10,
|
|
position: _11,
|
|
style: _12,
|
|
screenId: _13,
|
|
tableName: _14,
|
|
onRefresh: _15,
|
|
onClose: _16,
|
|
web_type: _17,
|
|
autoGeneration: _18,
|
|
isInteractive: _19,
|
|
formData: _20,
|
|
onFormDataChange: _21,
|
|
menuId: _22,
|
|
menuObjid: _23,
|
|
onSave: _24,
|
|
userId: _25,
|
|
userName: _26,
|
|
companyCode: _27,
|
|
isInModal: _28,
|
|
readonly: _29,
|
|
originalData: _30,
|
|
_originalData: _31,
|
|
_initialData: _32,
|
|
_groupedData: _33,
|
|
allComponents: _34,
|
|
onUpdateLayout: _35,
|
|
selectedRows: _36,
|
|
selectedRowsData: _37,
|
|
onSelectedRowsChange: _38,
|
|
sortBy: _39,
|
|
sortOrder: _40,
|
|
tableDisplayData: _41,
|
|
flowSelectedData: _42,
|
|
flowSelectedStepId: _43,
|
|
onFlowSelectedDataChange: _44,
|
|
onConfigChange: _45,
|
|
refreshKey: _46,
|
|
flowRefreshKey: _47,
|
|
onFlowRefresh: _48,
|
|
isPreview: _49,
|
|
groupedData: _50,
|
|
// ★ ButtonConfig 필드 — DOM 에 spread 되면 React warning. 제외.
|
|
text: _51,
|
|
variant: _52,
|
|
actionType: _53,
|
|
confirm: _54,
|
|
disabled: _55,
|
|
icon: _56,
|
|
iconPosition: _57,
|
|
backgroundColor: _58,
|
|
textColor: _59,
|
|
borderRadius: _60,
|
|
action: _61,
|
|
displayMode: _62,
|
|
iconTextPosition: _63,
|
|
iconGap: _64,
|
|
// 템플릿 컨텍스트 액션 콜백 — DOM 에 흘리지 않음
|
|
onAdd: _65,
|
|
onEdit: _66,
|
|
onDelete: _67,
|
|
onFormSubmit: _68,
|
|
onFormCancel: _69,
|
|
onSearch: _70,
|
|
searchParams: _71,
|
|
selectedRow: _72,
|
|
view: _73,
|
|
...domProps
|
|
} = props as any;
|
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
style={buttonStyle}
|
|
className={className}
|
|
onClick={handleClick}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
disabled={disabled}
|
|
{...filterDOMProps(domProps)}
|
|
>
|
|
{icon && iconPosition === "left" && (
|
|
<span aria-hidden>{/* TODO: lucide icon lookup (Phase F) */}{icon}</span>
|
|
)}
|
|
<span>{text}</span>
|
|
{icon && iconPosition === "right" && (
|
|
<span aria-hidden>{/* TODO: lucide icon lookup (Phase F) */}{icon}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export const ButtonWrapper: React.FC<ButtonComponentProps> = (props) => {
|
|
return <ButtonComponent {...props} />;
|
|
};
|