refactor: Enhance horizontal label handling in dynamic components

- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to improve horizontal label rendering and style management.
- Refactored the DynamicComponentRenderer to support external horizontal labels, ensuring proper display and positioning based on component styles.
- Cleaned up style handling by removing unnecessary border properties for horizontal labels, enhancing visual consistency.
- Improved the logic for determining label display requirements, streamlining the rendering process for dynamic components.
This commit is contained in:
DDD1542 2026-02-27 15:24:55 +09:00
parent 026e99511c
commit a8ad26cf30
3 changed files with 213 additions and 42 deletions

View File

@ -1119,6 +1119,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
const isSplitLine = type === "component" && compType === "v2-split-line";
@ -1194,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
const cleanedStyle = (isHorizLabel && needsExternalLabel)
? (() => {
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
return rest;
})()
: safeStyleWithoutSize;
const componentStyle = {
position: "absolute" as const,
...safeStyleWithoutSize,
...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
@ -1267,11 +1281,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe;
}, [component.id, position?.x, size?.width, type]);
// 라벨 위치가 top이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
const externalLabelComponent = needsExternalLabel ? (
<label
@ -1298,7 +1308,15 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
...splitAdjustedComponent.style,
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizLabel ? { width: "100%", height: "100%" } : {}),
...(isHorizLabel ? {
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
},
...(isHorizLabel ? {
size: {
@ -1314,21 +1332,47 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
<>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{needsExternalLabel ? (
<div
style={{
display: "flex",
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse",
alignItems: isHorizLabel ? "center" : undefined,
gap: isHorizLabel ? labelGapValue : undefined,
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}>
{renderInteractiveWidget(componentToRender)}
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
className="text-sm font-medium leading-none"
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(labelPos === "left"
? { right: "100%", marginRight: labelGapValue }
: { left: "100%", marginLeft: labelGapValue }),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
whiteSpace: "nowrap",
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column-reverse",
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0 }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
)
) : (
renderInteractiveWidget(componentToRender)
)}

View File

@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
const isV2HorizLabel = !!(
componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
);
const safeComponentStyle = isV2HorizLabel
? (() => {
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
return rest;
})()
: componentStyle;
const baseStyle = {
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
...componentStyle,
...safeComponentStyle,
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2,

View File

@ -371,15 +371,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => {
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
};
// 수평 라벨 감지
const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const catLabelPosition = component.style?.labelPosition;
const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true")
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
const catNeedsExternalHorizLabel = !!(
catLabelText &&
(catLabelPosition === "left" || catLabelPosition === "right")
);
// V2SelectRenderer용 컴포넌트 데이터 구성
const selectComponent = {
...component,
componentConfig: {
@ -395,6 +398,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
webType: "category",
};
const catStyle = catNeedsExternalHorizLabel
? {
...(component as any).style,
labelDisplay: false,
labelPosition: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
}
: (component as any).style;
const catSize = catNeedsExternalHorizLabel
? { ...(component as any).size, width: undefined, height: undefined }
: (component as any).size;
const rendererProps = {
component: selectComponent,
formData: props.formData,
@ -402,12 +423,47 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName,
style: (component as any).style,
size: (component as any).size,
style: catStyle,
size: catSize,
};
const rendererInstance = new V2SelectRenderer(rendererProps);
return rendererInstance.render();
const renderedCatSelect = rendererInstance.render();
if (catNeedsExternalHorizLabel) {
const labelGap = component.style?.labelGap || "8px";
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required;
const isLeft = catLabelPosition === "left";
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft
? { right: "100%", marginRight: labelGap }
: { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{catLabelText}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedCatSelect}
</div>
</div>
);
}
return renderedCatSelect;
} catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
@ -625,12 +681,33 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
const labelPosition = component.style?.labelPosition;
const isV2Component = componentType?.startsWith("v2-");
const needsExternalHorizLabel = !!(
isV2Component &&
effectiveLabel &&
(labelPosition === "left" || labelPosition === "right")
);
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
width: finalStyle.width,
height: finalStyle.height,
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
...(needsExternalHorizLabel ? {
labelDisplay: false,
labelPosition: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
};
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
@ -649,7 +726,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onClick,
onDragStart,
onDragEnd,
size: component.size || newComponent.defaultSize,
size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
: (component.size || newComponent.defaultSize),
position: component.position,
config: mergedComponentConfig,
componentConfig: mergedComponentConfig,
@ -657,8 +736,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel,
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: needsExternalHorizLabel ? undefined : effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,
@ -759,16 +838,51 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
NewComponentRenderer.prototype &&
NewComponentRenderer.prototype.render;
let renderedElement: React.ReactElement;
if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render();
renderedElement = rendererInstance.render();
} else {
// 함수형 컴포넌트
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
}
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
if (needsExternalHorizLabel) {
const labelGap = component.style?.labelGap || "8px";
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required;
const isLeft = labelPosition === "left";
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft
? { right: "100%", marginRight: labelGap }
: { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{effectiveLabel}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedElement}
</div>
</div>
);
}
return renderedElement;
}
} catch (error) {
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);