Files
invyone/frontend/lib/registry/components/button/ButtonComponent.tsx
T
DDD1542 3eda684787
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
2026-04-22 18:27:06 +09:00

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} />;
};