leo.dev
frontend

상태를 갖지 않는 레이아웃 컴포넌트

레이아웃 컴포넌트가 상태를 갖기 시작하면 거기서부터 무너진다. isOpen을 컴포넌트 안에서 useState로 들고, 탭 선택을 컴포넌트가 기억하면, 페이지는 그 상태를 다시 끌어내려고 props를 뚫고 제어권이 흩어진다.

그래서 desktop/components/layout/의 세 컴포넌트(PageHeader, SideDetailPanel, PageTableCard)는 한 규칙을 공유한다. 상태는 페이지 훅이 소유하고, 컴포넌트는 받아서 표시만 한다. 아래는 그 규칙을 슬롯, Controlled 패널, Compound Component로 구현한 방식이다.


1. PageHeader — 슬롯 패턴

PageHeader.tsx
interface PageHeaderProps {
title: string;
titleSuffix?: string; // 제목 오른쪽 보조 타이틀
subtitle?: string;
actions?: ReactNode; // 우측 버튼 영역
className?: string;
}

items-baseline

titletext-[30px], titleSuffixtext-[22px]로 크기가 다르다.

PageHeader.tsx
<h1 className="flex flex-wrap items-baseline gap-x-2 ...">
{title}
{titleSuffix && <span className="text-[22px] ...">{titleSuffix}</span>}
</h1>

items-center를 쓰면 두 텍스트가 중앙 정렬되어 어색하다. items-baseline은 폰트 베이스라인을 맞춰 자연스럽게 나란히 놓는다.

actions?: ReactNode — 슬롯 패턴

PageHeader가 버튼의 종류나 동작을 알아야 할 이유가 없다. ReactNode로 뚫어두면 사용처에서 자유롭게 채운다:

<PageHeader
title="직원 관리"
titleSuffix="Employees"
actions={<Button onClick={openCreateDrawer}>직원 추가</Button>}
/>

2. SideDetailPanel — Controlled 슬라이드 패널

SideDetailPanel.tsx
interface SideDetailPanelProps {
isOpen: boolean;
onClose: () => void;
width?: string; // 기본값 'w-[560px]'
ariaLabel?: string;
children?: React.ReactNode;
}

열기/닫기: CSS transform

SideDetailPanel.tsx
className={`fixed top-0 right-0 z-50 flex h-full ${width} flex-col bg-white shadow-2xl
transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}`}

isOpen이 false일 때 translate-x-full로 화면 오른쪽으로 완전히 밀어낸다. DOM에서 제거하지 않고 위치만 이동시키기 때문에 transition-transform duration-300이 적용돼 슬라이드 효과가 나온다.

display: none이나 visibility: hidden으로 숨기면 CSS transition이 작동하지 않는다. 트랜지션은 DOM에 존재하는 요소의 상태 변화에만 적용된다.

백드롭과 패널의 관계

SideDetailPanel.tsx
<>
{isOpen && (
<div className="fixed inset-0 z-40 bg-black/20" onClick={onClose} aria-hidden />
)}
<div className="fixed top-0 right-0 z-50 ...">
{children}
</div>
</>

백드롭(z-40)과 패널(z-50)이 형제로 나란히 있다. 백드롭은 isOpen일 때만 DOM에 존재하고, 패널은 항상 DOM에 있되 translate-x-full로 숨겨진다.

z-index 계층: 페이지 내용 < 백드롭(z-40) < 패널(z-50)

Escape 키 처리

SideDetailPanel.tsx
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);

세 포인트: (1) 패널이 닫혀 있을 때는 리스너를 등록하지 않는다. (2) cleanup으로 리스너를 반드시 제거한다. 제거하지 않으면 패널이 닫힌 후에도 Escape가 계속 작동한다. (3) [isOpen, onClose]가 의존성에 있어야 isOpen이 변할 때 effect가 재실행된다.

상태를 직접 소유하지 않는다

isOpenuseState로 내부에서 관리하지 않는다. 부모(훅)에서 관리하고, 이 컴포넌트는 표시와 닫기 이벤트만 담당한다. Controlled Component 원칙 그대로다.


3. PageTableCard — Compound Component 패턴

왜 단일 컴포넌트가 아닌가

테이블 페이지는 탭, 툴바, 테이블 헤더/바디, 페이지네이션 등 여러 구획으로 이루어진다. 하나의 컴포넌트로 만들면 props가 폭발한다:

// ❌ 하나의 컴포넌트로 만들었다면
<BigTableComponent
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
toolbarRight={...}
columns={columns}
data={data}
isLoading={isLoading}
page={page}
total={total}
onPageChange={onPageChange}
// ...
/>

유연성도 없다. 어떤 페이지는 탭이 없고, 어떤 페이지는 툴바에 특별한 요소가 들어간다.

Compound Component 패턴

PageTableCard.tsx
const PageTableCard = Object.assign(PageTableCardRoot, {
TabsRow, TabsWrap, Tabs, Tab, TabsDivider, TabsExtra,
Toolbar, ToolbarRight, SearchWrap,
TableWrap, Table, Thead, Th, Tbody, Tr, Td,
Loading, Empty, Pagination,
});

Object.assign으로 루트 컴포넌트에 서브 컴포넌트들을 프로퍼티로 붙인다. 사용처에서는 필요한 구획만 골라 조합한다:

<PageTableCard>
<PageTableCard.TabsRow>
<PageTableCard.TabsWrap>
<PageTableCard.Tabs>
<PageTableCard.Tab active={activeTab === 'all'} onClick={() => setActiveTab('all')}>
전체
</PageTableCard.Tab>
</PageTableCard.Tabs>
</PageTableCard.TabsWrap>
</PageTableCard.TabsRow>
<PageTableCard.Toolbar>
<PageTableCard.SearchWrap>
<input placeholder="검색..." />
</PageTableCard.SearchWrap>
<PageTableCard.ToolbarRight>
<Button>새로 만들기</Button>
</PageTableCard.ToolbarRight>
</PageTableCard.Toolbar>
<PageTableCard.TableWrap>
<PageTableCard.Table>
<PageTableCard.Thead>
<PageTableCard.Th>이름</PageTableCard.Th>
</PageTableCard.Thead>
<PageTableCard.Tbody>
{items.map(item => (
<PageTableCard.Tr key={item.id} selected={item.id === selectedId} onClick={() => openDetail(item)}>
<PageTableCard.Td>{item.name}</PageTableCard.Td>
</PageTableCard.Tr>
))}
</PageTableCard.Tbody>
</PageTableCard.Table>
</PageTableCard.TableWrap>
</PageTableCard>

탭이 필요 없으면 PageTableCard.TabsRow를 생략하면 된다.

pageTableCardStyles — 디자인 토큰 분리

PageTableCard.tsx
export const pageTableCardStyles = {
card: 'bg-white border border-gray-200 rounded-[14px] ...',
th: 'text-left px-6 py-3 text-xs font-medium ... uppercase bg-gray-50',
td: 'px-6 py-4 text-sm leading-5 text-[#0a0a0a] align-middle border-b border-gray-200',
// ...
};

Tailwind 클래스를 객체로 묶어 컴포넌트와 같은 PageTableCard.tsx에 두고 index.ts에서 함께 export한다. 다른 페이지에서 pageTableCardStyles.td를 가져와 동일한 셀 스타일을 재사용하고, 수정은 한 곳에서만 이루어진다.

Tr — selected + hover 상태 조합

PageTableCard.tsx
function Tr({ selected, children, className, ...rest }) {
const selectedCls = 'bg-[rgba(21,93,252,0.06)]';
const baseCls = 'border-b border-gray-200 transition-[background] duration-150 cursor-pointer hover:bg-gray-50';
const cls = selected
? `${baseCls} ${selectedCls} ${className ?? ''}`
: `${baseCls} ${className ?? ''}`;
return <tr className={cls.trim() || undefined} {...rest}>{children}</tr>;
}

selected prop 하나로 기본/선택 상태를 전환한다. 선택 배경은 rgba(21,93,252,0.06)이다. 탭 활성 색상(#155dfc)과 동일한 계열의 연한 파란색이다.

...rest 패턴

PageTableCard.tsx
function Tr({ selected, children, className, ...rest }
: { selected?: boolean; children: ReactNode; className?: string }
& React.HTMLAttributes<HTMLTableRowElement>
) {
return <tr className={cls} {...rest}>{children}</tr>;
}

TronClick을 명시적으로 선언할 필요가 없다. React.HTMLAttributes<HTMLTableRowElement>에 이미 포함되어 있으므로 ...rest로 자동으로 통과된다:

<PageTableCard.Tr onClick={() => openDetail(item)} data-testid="employee-row">

세 컴포넌트의 공통 원칙

원칙PageHeaderSideDetailPanelPageTableCard
상태를 직접 소유하지 않음✓ (actions는 부모가 정의)✓ (isOpen은 부모가 관리)✓ (탭/선택은 부모가 관리)
슬롯(ReactNode)으로 유연성 확보actionschildren각 구획 자체가 children
스타일만 담당, 데이터 로직 없음

껍데기 컴포넌트가 상태를 갖기 시작하는 순간, props drilling과 제어권 분산이 시작된다. 상태는 페이지 훅에, 레이아웃은 컴포넌트에 둔다. 이 분리가 유지될 때 각 레이어가 단순하게 남는다.

↑↓ 이동 열기esc 닫기