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