SidePanel
Layout
본문과 공간을 나눠 쓰는 측면 패널.
Usage
본문과 공간을 나눠 갖는 레이아웃 표면(목록+상세, 편집 사이드바). 본문을 덮는 임시 표면은 Drawer
import
import
import { SidePanel } from '@mildang/design-system/SidePanel';예제를 복사해 쓸 때 필요한 준비
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
API Reference
SidePanel Props
Prop
Type
Default
"line" | "gap"
line
폭과 반응형 전환
패널 폭은 Panel에서 정한다. size는 sm(360px)·md(720px) 스케일이고, width는 px 단위 custom 폭이다.
split과 Drawer의 전환점은 패널 폭에 본문 최소폭 600px을 더한 값이다. 좁아지면 패널은 Drawer로 바뀌고, 더 좁아지면 Drawer 규칙에 따라 full page가 된다.
drawerAt은 split 전환점을 덮어쓰며 본문 600px 하한도 뚫을 수 있다. fullPageAt은 custom width에서만 사용할 수 있고 패널 폭보다 낮아지지 않는다.
두 패널이 있으면 각 패널의 상태·폭·전환점은 독립적이며, Drawer 구간에서는 한 Root에 하나만 열린다. 전환은 렌더 트리 교체이므로 포커스와 패널 스크롤 위치가 초기화될 수 있다.
기본 사용
본문과 공간을 나눠 갖는 레이아웃형 패널이다. 겹쳐 뜨는 표면이 필요하면 Drawer, 화면이 좁아지면 자동으로 Drawer 로 전환된다.
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(위 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelDefaultExample() {
return (
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label="패널 열기" panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel>
<PanelContent title="Side Panel" />
</SidePanel.Panel>
</SidePanel>
);
}
열고 닫기와 접근성
Trigger로 토글하고 Close로 닫는다. 패널이 둘이면 Trigger에 anchor를 지정한다. split에서는 패널이 본문 옆 영역이고, Drawer로 전환되면 modal dialog가 된다.
Drawer 접근 가능한 이름을 위해 Title은 항상 둔다. Description은 보조 설명으로 사용한다. Header, Body, Footer는 패널 내부 슬롯이며 Body만 스크롤된다.
닫힌 패널은 visibility가 숨겨져 탭 순서와 스크린리더에서 제외된다. split에서 닫으면 포커스가 Trigger로 돌아가고, Drawer에서는 dialog 동작이 포커스를 복원한다.
기본 사용
본문과 공간을 나눠 갖는 레이아웃형 패널이다. 겹쳐 뜨는 표면이 필요하면 Drawer, 화면이 좁아지면 자동으로 Drawer 로 전환된다.
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(위 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelDefaultExample() {
return (
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label="패널 열기" panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel>
<PanelContent title="Side Panel" />
</SidePanel.Panel>
</SidePanel>
);
}
사용 가이드
권장
- 본문과 패널을 계속 함께 다뤄야 하는 화면에서 사용한다.
- 부모 높이를 확정하고 flex: 1; min-height: 0 체인을 구성한다.
- Drawer 전환 시에도 필요한 폼 상태는 패널 바깥에서 관리한다.
지양
- 집중이 필요한 단일 작업이나 잠깐 보는 상세에는 사용하지 않는다. Drawer 또는 Dialog를 사용한다.
- 상시 열려 있고 여닫지 않는 내비게이션 영역을 SidePanel로 만들지 않는다.
- controlled 패널에서 시스템의 onOpenChange(false)를 무시하지 않는다.
예제
구분 방식
divider 로 본문과 패널을 나누는 두 방식을 비교한다. line 은 맞닿는 면에 선을 긋고, gap 은 16px 여백과 양쪽 radius 를 만든다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(위 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
/** `gap` 용 셸 — 페이지 패딩과 회색 면만 준다. 면 2개와 그 사이 16px 은 컴포넌트가 만든다. */
const cardPageShellStyle = css({
height: '[460px]',
padding: '24',
backgroundColor: 'neutral.surface.high',
});
export default function SidePanelDividerExample() {
return (
<Flex direction="column" gap="32">
<VStack alignItems="stretch" gap="8">
<Text variant="title-sm">line — 맞붙고 선 하나로 갈린다</Text>
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label="패널 닫기" panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel defaultOpen>
<PanelContent title="line" />
</SidePanel.Panel>
</SidePanel>
</VStack>
<VStack alignItems="stretch" gap="8">
<Text variant="title-sm">gap — 16px 여백으로 갈린다</Text>
{/* 셸이 주는 건 페이지 패딩·배경뿐. 면과 그 사이 여백은 컴포넌트가 만든다. */}
<SidePanel divider="gap" className={cardPageShellStyle}>
<SidePanel.Main>
<MainContent label="패널 닫기" panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel defaultOpen>
<PanelContent title="gap" />
</SidePanel.Panel>
</SidePanel>
</VStack>
</Flex>
);
}
패널 위치
anchor 로 패널의 시각 순서를 뒤집는다. DOM 순서는 항상 본문 → 패널이다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelAnchorExample() {
return (
<Flex direction="column" gap="24">
{(['right', 'left'] as const).map((anchor) => (
<SidePanel key={anchor} className={shellStyle}>
<SidePanel.Main>
<MainContent label={`anchor="${anchor}"`} panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel anchor={anchor} defaultOpen>
<PanelContent title={anchor} />
</SidePanel.Panel>
</SidePanel>
))}
</Flex>
);
}
양쪽 패널
한 Root 안에 Panel 을 두 개 두고 각각 anchor 를 줘 좌우 양쪽에 패널을 배치하는 예시다. 열림 상태와 전환점은 패널마다 독립적이다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelAnchorBothExample() {
// 한쪽만 controlled 로 두고 다른 쪽은 uncontrolled 로 남겨, 둘이 섞여도 되는 걸 같이 보여준다.
const [leftOpen, setLeftOpen] = React.useState(true);
return (
<SidePanel className={shellStyle}>
<SidePanel.Panel anchor="left" open={leftOpen} onOpenChange={setLeftOpen}>
<PanelContent title="left" />
</SidePanel.Panel>
<SidePanel.Main>
<MainContent
anchors={['left', 'right']}
panels={{ left: { panelWidth: 360, drawerAt: 1320 }, right: DEFAULT_PANEL_READOUT }}
/>
</SidePanel.Main>
<SidePanel.Panel anchor="right" defaultOpen>
<PanelContent title="right" />
</SidePanel.Panel>
</SidePanel>
);
}
크기
sm(360px)·md(720px) 두 스케일의 전환점 차이를 나란히 비교한다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelSizesExample() {
return (
<Flex direction="column" gap="24">
{/* `md` 는 1320px 미만에서 Drawer 라, 기본으로 열어 두면 좁은 화면에서 위 예시까지 덮는다. */}
{(
[
{ size: 'sm', caption: 'size="sm" — 360px 패널 · 960px / 600px' },
{ size: 'md', caption: 'size="md" — 720px 패널 · 1320px / 720px' },
] as const
).map(({ size, caption }) => (
<VStack key={size} alignItems="stretch" gap="8">
<Text variant="title-sm">{caption}</Text>
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent
label={`size="${size}"`}
panel={{ panelWidth: size === 'sm' ? 360 : 720, drawerAt: size === 'sm' ? 960 : 1320 }}
/>
</SidePanel.Main>
<SidePanel.Panel size={size} defaultOpen={size === 'sm'}>
<PanelContent title={size} />
</SidePanel.Panel>
</SidePanel>
</VStack>
))}
</Flex>
);
}
Custom 폭
width 로 px 단위 custom 폭을 지정하고, drawerAt·fullPageAt 으로 전환점을 직접 덮어쓰는 예시다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelCustomWidthExample() {
return (
<Flex direction="column" gap="24">
<VStack alignItems="stretch" gap="8">
<Text variant="title-sm">{'width="480px"'} — 도출값 1080px / 600px</Text>
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label="패널 열기" panel={{ panelWidth: 480, drawerAt: 1080 }} />
</SidePanel.Main>
<SidePanel.Panel width="480px">
<PanelContent title="480px" />
</SidePanel.Panel>
</SidePanel>
</VStack>
<VStack alignItems="stretch" gap="8">
<Text variant="title-sm">{'+ drawerAt="1200px"'} — Drawer 전환점만 올라간다</Text>
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label="패널 열기" panel={{ panelWidth: 480, drawerAt: 1200 }} />
</SidePanel.Main>
<SidePanel.Panel width="480px" drawerAt="1200px">
<PanelContent title="480px + drawerAt" />
</SidePanel.Panel>
</SidePanel>
</VStack>
</Flex>
);
}
Controlled
open 을 직접 넘겨 controlled 로 다룬다. 모든 닫힘 경로가 onOpenChange 하나로 들어온다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelControlledExample() {
const [open, setOpen] = React.useState(false);
return (
<Flex direction="column" gap="12">
<Flex gap="8" alignItems="center">
<Button variant="tertiary" onClick={() => setOpen((prev) => !prev)}>
{open ? '닫기' : '열기'}
</Button>
<Text variant="body-md" color="neutral.text.low">
open: {String(open)}
</Text>
</Flex>
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label="패널 토글" panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel open={open} onOpenChange={setOpen}>
<PanelContent title="Controlled" />
</SidePanel.Panel>
</SidePanel>
</Flex>
);
}
본문 폭 변화
같은 폭의 셸 둘을 닫힘/열림으로 나란히 놓아 패널이 열릴 때 본문 폭만 줄어드는 걸 보인다.
코드
'use client';
import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
/**
* 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
*
* `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
* 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
* 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
*/
const useMeasuredWidth = () => {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
/**
* **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
*
* 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
* 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
*/
const useViewportWidth = () => {
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return width;
};
const parsePx = (value: string) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};
const MODE_LABEL = {
split: { text: '사이드바', role: 'positive' },
drawer: { text: 'Drawer', role: 'warning' },
fullPage: { text: '전체화면', role: 'critical' },
} as const;
/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;
/**
* full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
*
* Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
* (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
* 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
* `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
*/
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);
/**
* 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
*
* 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
* 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
*/
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;
type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };
const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };
const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
const viewport = useViewportWidth();
const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
const { text, role } = MODE_LABEL[current];
const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
const remaining = nextAt == null ? null : viewport - nextAt + 1;
return (
<Flex alignItems="center" gap="8" flexWrap="wrap">
{anchor && (
<Text variant="caption-lg" color="neutral.text.base">
{ANCHOR_LABEL[anchor]}
</Text>
)}
<Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
<Text variant="caption-lg" color="inverse.text.base">
{text}
</Text>
</Box>
<Text variant="caption-lg" color="neutral.text.low">
창 {viewport}px
</Text>
<Text variant="caption-lg" color="neutral.text.lowest">
전환점 · Drawer < {panel.drawerAt}px · 전체화면 < {fullPagePx}px
</Text>
{remaining != null && (
<Text variant="caption-lg" color="neutral.text.low">
{remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
</Text>
)}
</Flex>
);
};
const STATS = [
{ label: '진도율', value: '72%' },
{ label: '정답률', value: '88%' },
{ label: '학습 시간', value: '4h 20m' },
{ label: '오답 노트', value: '12개' },
{ label: '남은 과제', value: '3개' },
{ label: '연속 학습', value: '9일' },
];
// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });
const MainContent = ({
label,
anchors,
panel,
panels,
}: {
label?: string;
/** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
anchors?: readonly SidePanelAnchor[];
panel?: PanelReadout;
panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
const [ref, width] = useMeasuredWidth();
return (
<div ref={ref} className={mainStyle}>
<VStack alignItems="stretch" gap="16">
{/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
{anchors ? (
anchors.map((anchor) => (
<ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
))
) : (
<ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
)}
<Flex alignItems="center" justifyContent="space-between" gap="12">
<Text variant="title-md">학습 리포트</Text>
<Flex alignItems="center" gap="8" flexShrink="0">
{/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
<Box
paddingX="10"
paddingY="4"
borderRadius="full"
backgroundColor="neutral.surface.high"
flexShrink="0"
>
<Text variant="caption-lg" color="neutral.text.low">
본문 {width}px
</Text>
</Box>
{/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
{anchors
? anchors.map((anchor) => (
<SidePanel.Trigger key={anchor} anchor={anchor} asChild>
<Button variant="tertiary" size="sm">
{ANCHOR_LABEL[anchor]}
</Button>
</SidePanel.Trigger>
))
: label && (
<SidePanel.Trigger asChild>
<Button variant="tertiary" size="sm">
{label}
</Button>
</SidePanel.Trigger>
)}
</Flex>
</Flex>
{/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
<Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
{STATS.map(({ label: statLabel, value }) => (
<VStack
key={statLabel}
alignItems="flex-start"
gap="4"
padding="16"
borderRadius="12"
borderWidth="1px"
borderStyle="solid"
borderColor="neutral.border.base"
>
<Text variant="caption-lg" color="neutral.text.low">
{statLabel}
</Text>
<Text variant="title-sm">{value}</Text>
</VStack>
))}
</Grid>
<Text variant="body-md" color="neutral.text.low">
패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
</Text>
</VStack>
</div>
);
};
const PanelContent = ({ title }: { title: string }) => (
<>
<SidePanel.Header>
<SidePanel.Title>{title}</SidePanel.Title>
<SidePanel.Close asChild>
<Button variant="tertiary" size="sm">
닫기
</Button>
</SidePanel.Close>
</SidePanel.Header>
<SidePanel.Body>
<VStack alignItems="stretch" gap="12">
{/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
<SidePanel.Description>
본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
</SidePanel.Description>
{['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
<VStack
key={item}
alignItems="flex-start"
gap="4"
padding="12"
borderRadius="8"
backgroundColor="neutral.surface.high"
>
<Text variant="body-sm">{item}</Text>
<Text variant="caption-lg" color="neutral.text.low">
다시 풀어보기
</Text>
</VStack>
))}
</VStack>
</SidePanel.Body>
<SidePanel.Footer>
<SidePanel.Button variant="primary">저장</SidePanel.Button>
</SidePanel.Footer>
</>
);
/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
height: '[460px]',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.base',
overflow: 'hidden',
});
export default function SidePanelSplitBehaviorExample() {
return (
<Flex direction="column" gap="24">
{(
[
{ label: '닫힘 — 본문이 자리를 다 쓴다', open: false, trigger: '패널 열기' },
{ label: '열림 — 셸은 그대로, 본문만 360px 줄었다', open: true, trigger: '패널 닫기' },
] as const
).map(({ label, open, trigger }) => (
<VStack key={label} alignItems="stretch" gap="8">
<Text variant="title-sm">{label}</Text>
<SidePanel className={shellStyle}>
<SidePanel.Main>
<MainContent label={trigger} panel={DEFAULT_PANEL_READOUT} />
</SidePanel.Main>
<SidePanel.Panel defaultOpen={open}>
<PanelContent title="Side Panel" />
</SidePanel.Panel>
</SidePanel>
</VStack>
))}
</Flex>
);
}