utils_react-nexacro_layouts.jsx
import React from "react";
/**
* 자식 컴포넌트들을 수직 또는 수평으로 배치하는 스택 컨테이너.
* 넥사크로의 상대 좌표계(id:gap)를 활용하여 동적으로 배치합니다.
*
* @function NxStack
* @param {Object} props
* @param {'vertical' | 'horizontal'} [props.direction="vertical"]
* 배치 방향
* @param {boolean} [props.reverse=false]
* 역방향 배치 여부
* @param {number | string} [props.gap=0]
* 자식 간의 간격(px)
* @param {number} [props.p=0]
* 전체 여백
* @param {number} [props.px=0]
* 좌우 여백
* @param {number} [props.py=0]
* 상하 여백
* @param {number} [props.pt=0]
* 상단 여백
* @param {number} [props.pb=0]
* 하단 여백
* @param {number} [props.pl=0]
* 좌측 여백
* @param {number} [props.pr=0]
* 우측 여백
* @returns {React.ReactElement}
* @memberof $react
* @example
* React.createElement(
* NxStack,
* { direction: "vertical", gap: 10, left: 0, top: 0, width: 200 },
* React.createElement(NxButton, { text: "Button 1", height: 30 }),
* React.createElement(NxButton, { text: "Button 2", height: 30 }),
* );
*
*/
export function NxStack(props) {
const {
direction = "vertical",
reverse = false,
gap = 0,
p,
px,
py,
pt,
pb,
pl,
pr,
children,
...divProps
} = props;
const stackRef = React.useRef({
prefix: (divProps.id ? getSafeNexacroId(divProps.id) : null) || ("v_stack_" + Math.random().toString(36).substring(2, 11)),
keyMap: new Map(),
nextIdx: 0,
renderCount: 0,
});
stackRef.current.renderCount++;
// key에 매핑된 index를 반환하는 헬퍼 함수
const getSafeIndex = (key, fallbackIdx) => {
if (key == null) return fallbackIdx;
let idx = stackRef.current.keyMap.get(key);
if (idx === undefined) {
idx = stackRef.current.nextIdx++;
stackRef.current.keyMap.set(key, idx);
}
return idx;
};
const finalPt = pt ?? py ?? p ?? 0;
const finalPb = pb ?? py ?? p ?? 0;
const finalPl = pl ?? px ?? p ?? 0;
const finalPr = pr ?? px ?? p ?? 0;
const childArray = React.Children.toArray(children).filter(
React.isValidElement,
);
const elements = [];
let prevMainId = null;
const seenKeys = new Set();
if (direction === "vertical") {
if (!reverse) {
childArray.forEach((child, i) => {
let actualKey = child.key;
if (actualKey != null) {
let dupCount = 0;
while (seenKeys.has(actualKey)) {
dupCount++;
actualKey = child.key + "_dup" + dupCount;
}
seenKeys.add(actualKey);
}
const childSafeKey = getSafeIndex(actualKey, i);
const rawChildId = child.props.id;
const childId = rawChildId
? getSafeNexacroId(rawChildId)
: getSafeNexacroId(stackRef.current.prefix + "_" + childSafeKey);
const childProps = { id: childId, _forceLayout: stackRef.current.renderCount };
const childTopOffset = i === 0 ? (child.props.top ?? 0) : gap;
childProps.top = prevMainId
? `${prevMainId}:${childTopOffset}`
: childTopOffset;
childProps.bottom = null; // top 정렬이므로 bottom은 null
childProps.left = child.props.left ?? 0;
childProps.right = child.props.right ?? null;
elements.push(React.cloneElement(child, childProps));
prevMainId = childId;
});
// fittocontents="height"인 경우 패딩 높이를 하단에 반영하기 위해 더미 디브 배치
if (finalPb > 0) {
const padBottomId = stackRef.current.prefix + "_pad_bottom";
elements.push(
<nx-div
key="pad_bottom"
id={padBottomId}
height={finalPb}
top={prevMainId ? `${prevMainId}:0` : 0}
left={0}
right={null}
/>
);
}
} else {
for (let i = childArray.length - 1; i >= 0; i--) {
const child = childArray[i];
let actualKey = child.key;
if (actualKey != null) {
let dupCount = 0;
while (seenKeys.has(actualKey)) {
dupCount++;
actualKey = child.key + "_dup" + dupCount;
}
seenKeys.add(actualKey);
}
const childSafeKey = getSafeIndex(actualKey, i);
const rawChildId = child.props.id;
const childId = rawChildId
? getSafeNexacroId(rawChildId)
: getSafeNexacroId(stackRef.current.prefix + "_" + childSafeKey);
const childProps = { id: childId, _forceLayout: stackRef.current.renderCount };
// reverse=true 이면 하단 패딩(finalPb)을 맨 하단 컴포넌트의 bottom 오프셋에 가산하여 실질적으로 하단 여백 생성
const childBottomOffset =
i === childArray.length - 1
? (child.props.bottom ?? 0) + finalPb
: gap;
childProps.bottom = prevMainId
? `${prevMainId}:${childBottomOffset}`
: childBottomOffset;
childProps.top = null; // bottom 정렬이므로 top은 null
childProps.left = child.props.left ?? 0;
childProps.right = child.props.right ?? null;
elements.push(React.cloneElement(child, childProps));
prevMainId = childId;
}
}
} else {
// horizontal
if (!reverse) {
childArray.forEach((child, i) => {
let actualKey = child.key;
if (actualKey != null) {
let dupCount = 0;
while (seenKeys.has(actualKey)) {
dupCount++;
actualKey = child.key + "_dup" + dupCount;
}
seenKeys.add(actualKey);
}
const childSafeKey = getSafeIndex(actualKey, i);
const rawChildId = child.props.id;
const childId = rawChildId
? getSafeNexacroId(rawChildId)
: getSafeNexacroId(stackRef.current.prefix + "_" + childSafeKey);
const childProps = { id: childId, _forceLayout: stackRef.current.renderCount };
const childLeftOffset = i === 0 ? (child.props.left ?? 0) : gap;
childProps.left = prevMainId
? `${prevMainId}:${childLeftOffset}`
: childLeftOffset;
childProps.right = null; // left 정렬이므로 right는 null
childProps.top = child.props.top ?? 0;
childProps.bottom = child.props.bottom ?? null;
elements.push(React.cloneElement(child, childProps));
prevMainId = childId;
});
// fittocontents="width"인 경우 패딩 너비를 우측에 반영하기 위해 더미 디브 배치
if (finalPr > 0) {
const padRightId = stackRef.current.prefix + "_pad_right";
elements.push(
<nx-div
key="pad_right"
id={padRightId}
width={finalPr}
left={prevMainId ? `${prevMainId}:0` : 0}
top={0}
bottom={null}
/>
);
}
} else {
for (let i = childArray.length - 1; i >= 0; i--) {
const child = childArray[i];
let actualKey = child.key;
if (actualKey != null) {
let dupCount = 0;
while (seenKeys.has(actualKey)) {
dupCount++;
actualKey = child.key + "_dup" + dupCount;
}
seenKeys.add(actualKey);
}
const childSafeKey = getSafeIndex(actualKey, i);
const rawChildId = child.props.id;
const childId = rawChildId
? getSafeNexacroId(rawChildId)
: getSafeNexacroId(stackRef.current.prefix + "_" + childSafeKey);
const childProps = { id: childId, _forceLayout: stackRef.current.renderCount };
// reverse=true 이면 우측 패딩(finalPr)을 맨 우측 컴포넌트의 right 오프셋에 가산하여 실질적으로 우측 여백 생성
const childRightOffset =
i === childArray.length - 1
? (child.props.right ?? 0) + finalPr
: gap;
childProps.right = prevMainId
? `${prevMainId}:${childRightOffset}`
: childRightOffset;
childProps.left = null; // right 정렬이므로 left는 null
childProps.top = child.props.top ?? 0;
childProps.bottom = child.props.bottom ?? null;
elements.push(React.cloneElement(child, childProps));
prevMainId = childId;
}
}
}
const isValValid = (val) => {
if (val == null) return false;
if (typeof val === "number") return val > 0;
if (typeof val === "string") {
const parsed = parseFloat(val);
return !isNaN(parsed) && parsed > 0;
}
return false;
};
const hasPadding = isValValid(finalPt) || isValValid(finalPb) || isValValid(finalPl) || isValValid(finalPr);
if (hasPadding) {
const innerId = stackRef.current.prefix + "_inner";
const innerFittocontents = divProps.fittocontents;
// vertical 이면 bottom 패딩은 내부에 구현하므로 inner nx-div의 bottom은 0
// horizontal 이면 right 패딩은 내부에 구현하므로 inner nx-div의 right은 0
const innerPl = finalPl;
const innerPt = finalPt;
const innerPr = direction === "horizontal" ? 0 : finalPr;
const innerPb = direction === "vertical" ? 0 : finalPb;
return (
<nx-div {...divProps}>
<nx-div
id={innerId}
left={innerPl}
top={innerPt}
right={innerPr}
bottom={innerPb}
fittocontents={innerFittocontents}
>
{elements}
</nx-div>
</nx-div>
);
}
return <nx-div {...divProps}>{elements}</nx-div>;
}
/**
* 수직(Vertical)으로 자식들을 배치하는 박스 컨테이너.
*
* @function NxVBox
* @param {Object} props
* @param {boolean} [props.reverse=false]
* 역방향 배치 여부
* @param {number | string} [props.gap=0]
* 간격
* @param {number} [props.p=0]
* 전체 여백
* @param {number} [props.px=0]
* 좌우 여백
* @param {number} [props.py=0]
* 상하 여백
* @param {number} [props.pt=0]
* 상단 여백
* @param {number} [props.pb=0]
* 하단 여백
* @param {number} [props.pl=0]
* 좌측 여백
* @param {number} [props.pr=0]
* 우측 여백
* @returns {React.ReactElement}
* @memberof $react
*/
export function NxVBox(props) {
return <NxStack {...props} direction="vertical" />;
}
/**
* 수평(Horizontal)으로 자식들을 배치하는 박스 컨테이너.
*
* @function NxHBox
* @param {Object} props
* @param {boolean} [props.reverse=false]
* 역방향 배치 여부
* @param {number | string} [props.gap=0]
* 간격
* @param {number} [props.p=0]
* 전체 여백
* @param {number} [props.px=0]
* 좌우 여백
* @param {number} [props.py=0]
* 상하 여백
* @param {number} [props.pt=0]
* 상단 여백
* @param {number} [props.pb=0]
* 하단 여백
* @param {number} [props.pl=0]
* 좌측 여백
* @param {number} [props.pr=0]
* 우측 여백
* @returns {React.ReactElement}
* @memberof $react
*/
export function NxHBox(props) {
return <NxStack {...props} direction="horizontal" />;
}
/**
* 넥사크로 컴포넌트 ID 규칙에 부합하도록 특수문자를 제거하고 정제하는 헬퍼 함수
*
* @param {string} id - 입력 컴포넌트 ID
* @returns {string} 정제된 넥사크로 ID
*/
function getSafeNexacroId(id) {
if (!id) return id;
let safeId = id.replace(/[^A-Za-z0-9_]/g, "_");
if (/^[0-9]/.test(safeId)) {
safeId = "_" + safeId;
}
return safeId;
}