PropertyPanel
Data Display
Figma Design 패널 스타일의 속성 편집 레이아웃.
Usage
선택한 객체의 여러 속성을 섹션·행으로 나눠 편집하는 사이드 패널 좌표·크기처럼 여러 칸의 숫자 입력을 한 행에 묶어야 하는 편집 UI
import
import
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';PropertyPanel 하나만 가져오면 PropertyPanel.Root · PropertyPanel.Section · PropertyPanel.Item · PropertyPanel.EmptyHint · PropertyPanel.Row · PropertyPanel.Header 를 그 아래에서 쓸 수 있다.
예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
Anatomy
tsx
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';
export default function Example() {
return (
<PropertyPanel>
<PropertyPanel.Header />
<PropertyPanel.Section>
<PropertyPanel.Row />
<PropertyPanel.Item />
<PropertyPanel.EmptyHint />
</PropertyPanel.Section>
</PropertyPanel>
);
}부품
필수 여부
반복
위치
PropertyPanel.Root
PropertyPanel.Header
PropertyPanel.Root 안 최상단에 둔다.
PropertyPanel.Section
여러 개 가능
PropertyPanel.Root 안에 둔다.
PropertyPanel.Row
여러 개 가능
PropertyPanel.Section 안에 둔다.
PropertyPanel.Item
여러 개 가능
PropertyPanel.Section 안에 둔다.
PropertyPanel.EmptyHint
PropertyPanel.Section 안, Item 대신 둔다.
- Section 안에는 Item 또는 EmptyHint 중 상황에 맞는 쪽을 둔다 — 비어 있으면 EmptyHint, 편집 가능한 속성이 있으면 Item·Row다.
API Reference
PropertyPanel Props
Prop
Type
Default
ReactNode
지정 안 함
"plain" | "card"
plain
PropertyPanel.Header
패널 제목·뒤로가기·trailing 액션을 담는 상단 헤더다.
Prop
Type
Default
ReactNode
지정 안 함
ReactNode
지정 안 함
{ label: string; onClick: () => void; }
지정 안 함
ReactNode
지정 안 함
PropertyPanel.Section
관련 속성을 묶는 구획이다. 제목을 주면 섹션 헤더가 함께 렌더된다.
Prop
Type
Default
ReactNode
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
PropertyPanel.Row
아이콘·제목·caption·meta 를 한 줄로 보여주는 목록형 행이다.
Prop
Type
Default
ReactNode
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
boolean
지정 안 함
PropertyPanel.Item
라벨과 편집 필드(들)를 묶는 한 속성 단위다. children 으로 필드를 2~3칸 나란히 배치할 수 있다.
Prop
Type
Default
ReactNode
지정 안 함
ReactNode
지정 안 함
PropertyPanel.EmptyHint
Item 이 없는 빈 섹션에 보여주는 안내 문구다.
Prop
Type
Default
ReactNode
지정 안 함
예제
기본 사용
정렬·위치·회전 등 여러 Item을 Section으로 묶은 전체 패널 구성이다.
코드
import { useState } from 'react';
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';
import { SegmentedControl as SegmentedControl } from '@mildang/design-system/SegmentedControl';
import FormatAlignLeft from '@mildang/icons/react/editor/format-align-left';
import { VisuallyHidden } from '@mildang/styled-system/jsx';
import FormatAlignCenter from '@mildang/icons/react/editor/format-align-center';
import FormatAlignRight from '@mildang/icons/react/editor/format-align-right';
import FormatAlignTop from '@mildang/icons/react/editor/format-align-top';
import FormatAlignVertically from '@mildang/icons/react/editor/format-align-vertically';
import FormatAlignBottom from '@mildang/icons/react/editor/format-align-bottom';
import { Input } from '@mildang/design-system/Input';
import Refresh from '@mildang/icons/react/refresh';
import ImageHorizontalAlign from '@mildang/icons/react/editor/image_horizontal_align';
import ImageVerticalAlign from '@mildang/icons/react/editor/image_vertical_align';
import ImageLeftAlign from '@mildang/icons/react/editor/image_left_align';
import ImageMiddleAlign from '@mildang/icons/react/editor/image_middle_align';
import ImageRightAlign from '@mildang/icons/react/editor/image_right_align';
import { Select, createSelectCollection } from '@mildang/design-system/Select';
import { IconButton } from '@mildang/design-system/IconButton';
import Link from '@mildang/icons/react/link';
import Unlink from '@mildang/icons/react/unlink';
import { Slider } from '@mildang/design-system/unofficial/Slider';
import Eye from '@mildang/icons/react/eye';
import { Text } from '@mildang/design-system/Text';
import Filter from '@mildang/icons/react/filter';
import DragHandle from '@mildang/icons/react/drag-handle';
import Add from '@mildang/icons/react/add';
import { ColorField } from '@mildang/design-system/unofficial/ColorField';
import Remove from '@mildang/icons/react/remove';
import { css } from '@mildang/styled-system/css';
/** 우측 사이드바 폭(260px)으로 실제 사용 맥락을 재현하는 컨테이너 */
const sidebar = css({
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
});
const basicSidebarSize = { width: 500, height: 1100 } as const;
const opacityRowStyle = css({
display: 'grid',
gridTemplateColumns: '96px minmax(0, 1fr) auto',
alignItems: 'center',
gap: '8',
});
const fillFieldStyle = css({ gridColumn: '1 / 3' });
const twoColumnActionRowStyle = css({
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr) auto',
alignItems: 'center',
gap: '8',
});
const horizontalResizeCollection = createSelectCollection({
items: [
{ value: 'fill', label: '채우기 (496)' },
{ value: 'hug', label: '내용 맞춤 (48)' },
{ value: 'fixed', label: '고정 (320)' },
],
});
const verticalResizeCollection = createSelectCollection({
items: [
{ value: 'hug', label: '내용 맞춤 (48)' },
{ value: 'fill', label: '채우기 (180)' },
{ value: 'fixed', label: '고정 (180)' },
],
});
const blendModeCollection = createSelectCollection({
items: [
{ value: 'pass-through', label: '통과' },
{ value: 'normal', label: '일반' },
{ value: 'multiply', label: '곱하기' },
],
});
const typographyStyleCollection = createSelectCollection({
items: [
{ value: 'body', label: 'UI/본문' },
{ value: 'title', label: 'UI/제목' },
{ value: 'caption', label: 'UI/캡션' },
],
});
function BasicStory() {
const [horizontalAlign, setHorizontalAlign] = useState('left');
const [verticalAlign, setVerticalAlign] = useState('top');
const [textAlign, setTextAlign] = useState('left');
const [textVerticalAlign, setTextVerticalAlign] = useState('top');
const [resizeMode, setResizeMode] = useState('auto');
const [horizontalResize, setHorizontalResize] = useState(['fill']);
const [verticalResize, setVerticalResize] = useState(['hug']);
const [blendMode, setBlendMode] = useState(['pass-through']);
const [typographyStyle, setTypographyStyle] = useState(['body']);
const [x, setX] = useState('0');
const [y, setY] = useState('20');
const [rotation, setRotation] = useState('0');
const [opacity, setOpacity] = useState(75);
const [fill, setFill] = useState('neutral.text.low');
return (
<div className={sidebar} style={basicSidebarSize}>
<PropertyPanel.Root aria-label="속성 패널">
<PropertyPanel.Section aria-label="변형" pt="16">
<PropertyPanel.Item label="정렬">
<SegmentedControl
size="sm"
fullWidth
value={horizontalAlign}
onValueChange={setHorizontalAlign}
aria-label="가로 정렬"
>
<SegmentedControl.Item value="left" startIcon={<FormatAlignLeft />}>
<VisuallyHidden>왼쪽</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="center" startIcon={<FormatAlignCenter />}>
<VisuallyHidden>가운데</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="right" startIcon={<FormatAlignRight />}>
<VisuallyHidden>오른쪽</VisuallyHidden>
</SegmentedControl.Item>
</SegmentedControl>
<SegmentedControl
size="sm"
fullWidth
value={verticalAlign}
onValueChange={setVerticalAlign}
aria-label="세로 정렬"
>
<SegmentedControl.Item value="top" startIcon={<FormatAlignTop />}>
<VisuallyHidden>위</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="middle" startIcon={<FormatAlignVertically />}>
<VisuallyHidden>가운데</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="bottom" startIcon={<FormatAlignBottom />}>
<VisuallyHidden>아래</VisuallyHidden>
</SegmentedControl.Item>
</SegmentedControl>
</PropertyPanel.Item>
<PropertyPanel.Item label="위치">
<Input
size="sm"
fullWidth
startAdornment="X"
type="number"
value={x}
onChange={(e) => setX(e.target.value)}
aria-label="X 좌표"
/>
<Input
size="sm"
fullWidth
startAdornment="Y"
type="number"
value={y}
onChange={(e) => setY(e.target.value)}
aria-label="Y 좌표"
/>
</PropertyPanel.Item>
<PropertyPanel.Item label="회전">
<Input
size="sm"
fullWidth
endAdornment="°"
type="number"
value={rotation}
onChange={(e) => setRotation(e.target.value)}
aria-label="회전"
/>
<SegmentedControl size="sm" fullWidth disabled aria-label="뒤집기">
<SegmentedControl.Item value="rotate" startIcon={<Refresh />}>
<VisuallyHidden>90도 회전</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="horizontal" startIcon={<ImageHorizontalAlign />}>
<VisuallyHidden>가로 뒤집기</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="vertical" startIcon={<ImageVerticalAlign />}>
<VisuallyHidden>세로 뒤집기</VisuallyHidden>
</SegmentedControl.Item>
</SegmentedControl>
</PropertyPanel.Item>
</PropertyPanel.Section>
<PropertyPanel.Section title="레이아웃">
<PropertyPanel.Item label="크기 조절">
<SegmentedControl
size="sm"
fullWidth
value={resizeMode}
onValueChange={setResizeMode}
aria-label="크기 조절 방식"
>
<SegmentedControl.Item value="fixed" startIcon={<ImageLeftAlign />}>
<VisuallyHidden>고정</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="auto" startIcon={<ImageMiddleAlign />}>
<VisuallyHidden>자동</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="fill" startIcon={<ImageRightAlign />}>
<VisuallyHidden>채우기</VisuallyHidden>
</SegmentedControl.Item>
</SegmentedControl>
</PropertyPanel.Item>
<PropertyPanel.Item label="크기 조절">
<div className={twoColumnActionRowStyle}>
<Select
size="sm"
fullWidth
startAdornment="W"
hideClearButton
collection={horizontalResizeCollection}
value={horizontalResize}
onValueChange={(details) => setHorizontalResize(details.value)}
>
{horizontalResizeCollection.items.map((item) => (
<Select.Option key={item.value} item={item} />
))}
</Select>
<Select
size="sm"
fullWidth
startAdornment="H"
hideClearButton
collection={verticalResizeCollection}
value={verticalResize}
onValueChange={(details) => setVerticalResize(details.value)}
>
{verticalResizeCollection.items.map((item) => (
<Select.Option key={item.value} item={item} />
))}
</Select>
<IconButton size="xs" variant="tertiary" aria-label="가로세로 비율 고정">
<Link />
</IconButton>
</div>
</PropertyPanel.Item>
</PropertyPanel.Section>
<PropertyPanel.Section
title="모양"
actions={
<IconButton size="xs" variant="tertiary" aria-label="모양 속성 연결 해제">
<Unlink />
</IconButton>
}
>
<PropertyPanel.Item label="불투명도">
<div className={opacityRowStyle}>
<Input
size="sm"
fullWidth
endAdornment="%"
type="number"
min={0}
max={100}
value={String(opacity)}
onChange={(event) => setOpacity(Math.min(100, Math.max(0, Number(event.target.value))))}
aria-label="불투명도 숫자"
/>
<Slider
value={opacity}
min={0}
max={100}
step={1}
onValueChange={setOpacity}
formatValue={(value) => `${value}%`}
aria-label="불투명도"
tooltip
/>
<IconButton size="xs" variant="tertiary" aria-label="레이어 표시">
<Eye />
</IconButton>
</div>
</PropertyPanel.Item>
<PropertyPanel.Item label="혼합 모드">
<Select
size="sm"
fullWidth
hideClearButton
collection={blendModeCollection}
value={blendMode}
onValueChange={(details) => setBlendMode(details.value)}
>
{blendModeCollection.items.map((item) => (
<Select.Option key={item.value} item={item} />
))}
</Select>
</PropertyPanel.Item>
</PropertyPanel.Section>
<PropertyPanel.Section
title="타이포그래피"
actions={
<IconButton size="xs" variant="tertiary" aria-label="타이포그래피 초기화">
<Refresh />
</IconButton>
}
>
<Select
size="md"
fullWidth
hideClearButton
aria-label="텍스트 스타일"
startAdornment={
<Text as="span" variant="title-2xl">
Ag
</Text>
}
collection={typographyStyleCollection}
value={typographyStyle}
onValueChange={(details) => setTypographyStyle(details.value)}
>
{typographyStyleCollection.items.map((item) => (
<Select.Option key={item.value} item={item} />
))}
</Select>
<PropertyPanel.Item label="정렬">
<div className={twoColumnActionRowStyle}>
<SegmentedControl
size="sm"
fullWidth
value={textAlign}
onValueChange={setTextAlign}
aria-label="텍스트 가로 정렬"
>
<SegmentedControl.Item value="left" startIcon={<FormatAlignLeft />}>
<VisuallyHidden>왼쪽</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="center" startIcon={<FormatAlignCenter />}>
<VisuallyHidden>가운데</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="right" startIcon={<FormatAlignRight />}>
<VisuallyHidden>오른쪽</VisuallyHidden>
</SegmentedControl.Item>
</SegmentedControl>
<SegmentedControl
size="sm"
fullWidth
value={textVerticalAlign}
onValueChange={setTextVerticalAlign}
aria-label="텍스트 세로 정렬"
>
<SegmentedControl.Item value="top" startIcon={<FormatAlignTop />}>
<VisuallyHidden>위</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="middle" startIcon={<FormatAlignVertically />}>
<VisuallyHidden>가운데</VisuallyHidden>
</SegmentedControl.Item>
<SegmentedControl.Item value="bottom" startIcon={<FormatAlignBottom />}>
<VisuallyHidden>아래</VisuallyHidden>
</SegmentedControl.Item>
</SegmentedControl>
<IconButton size="xs" variant="tertiary" aria-label="타이포그래피 세부 설정">
<Filter />
</IconButton>
</div>
</PropertyPanel.Item>
</PropertyPanel.Section>
<PropertyPanel.Section
title="채우기"
actions={
<>
<IconButton size="xs" variant="tertiary" aria-label="채우기 순서 변경">
<DragHandle />
</IconButton>
<IconButton size="xs" variant="tertiary" aria-label="채우기 추가">
<Add />
</IconButton>
</>
}
>
<PropertyPanel.Item>
<div className={opacityRowStyle}>
<div className={fillFieldStyle}>
<ColorField
value={fill}
onChange={setFill}
label="비활성 요소 색상"
swatches={[
'neutral.text.low',
'neutral.text.base',
'brand.surface.base',
'positive.surface.base',
]}
/>
</div>
<IconButton size="xs" variant="tertiary" aria-label="채우기 제거">
<Remove />
</IconButton>
</div>
</PropertyPanel.Item>
</PropertyPanel.Section>
<PropertyPanel.Section
title="테두리"
actions={
<IconButton size="xs" variant="tertiary" aria-label="테두리 추가">
<Add />
</IconButton>
}
/>
<PropertyPanel.Section
title="효과"
actions={
<IconButton size="xs" variant="tertiary" aria-label="효과 추가">
<Add />
</IconButton>
}
/>
<PropertyPanel.Section
title="내보내기"
actions={
<IconButton size="xs" variant="tertiary" aria-label="내보내기 설정 추가">
<Add />
</IconButton>
}
/>
</PropertyPanel.Root>
</div>
);
}
const PropertyPanelBasicExample = () => <BasicStory />;
export default PropertyPanelBasicExample;
2칸 필드
x·y 좌표처럼 두 입력을 한 행에 나란히 배치하는 구성이다.
코드
import { useState } from 'react';
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';
import { Input } from '@mildang/design-system/Input';
import { css } from '@mildang/styled-system/css';
/** 우측 사이드바 폭(260px)으로 실제 사용 맥락을 재현하는 컨테이너 */
const sidebar = css({
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
});
/** Panda 토큰 없는 데모 전용 사이즈는 inline style로 (스토리 스캐폴딩) */
const sidebarSize = { width: 260, height: 620 } as const;
function TwoColumnFieldsStory() {
const [x, setX] = useState('120');
const [y, setY] = useState('64');
return (
<div className={sidebar} style={sidebarSize}>
<PropertyPanel.Root aria-label="2칸 좌표 입력">
<PropertyPanel.Section title="위치">
<PropertyPanel.Item label="좌표">
<Input
size="sm"
fullWidth
startAdornment="X"
type="number"
value={x}
onChange={(e) => setX(e.target.value)}
aria-label="X 좌표"
/>
<Input
size="sm"
fullWidth
startAdornment="Y"
type="number"
value={y}
onChange={(e) => setY(e.target.value)}
aria-label="Y 좌표"
/>
</PropertyPanel.Item>
</PropertyPanel.Section>
</PropertyPanel.Root>
</div>
);
}
const PropertyPanelTwoColumnFieldsExample = () => <TwoColumnFieldsStory />;
export default PropertyPanelTwoColumnFieldsExample;
3칸 필드
x·y·z 3차원 좌표처럼 세 입력을 한 행에 배치하는 구성이다.
코드
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';
import { Input } from '@mildang/design-system/Input';
import { css } from '@mildang/styled-system/css';
/** 우측 사이드바 폭(260px)으로 실제 사용 맥락을 재현하는 컨테이너 */
const sidebar = css({
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
});
/** Panda 토큰 없는 데모 전용 사이즈는 inline style로 (스토리 스캐폴딩) */
const sidebarSize = { width: 260, height: 620 } as const;
const PropertyPanelThreeColumnFieldsExample = () => (
<div className={sidebar} style={sidebarSize}>
<PropertyPanel.Root aria-label="3칸 좌표 입력">
<PropertyPanel.Section title="위치">
<PropertyPanel.Item label="3차원 좌표">
<Input size="sm" fullWidth startAdornment="X" defaultValue="120" aria-label="X 좌표" />
<Input size="sm" fullWidth startAdornment="Y" defaultValue="64" aria-label="Y 좌표" />
<Input size="sm" fullWidth startAdornment="Z" defaultValue="0" aria-label="Z 좌표" />
</PropertyPanel.Item>
</PropertyPanel.Section>
</PropertyPanel.Root>
</div>
);
export default PropertyPanelThreeColumnFieldsExample;
빈 섹션
Item 없이 EmptyHint만 보여주는 빈 섹션 상태다.
코드
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';
import { IconButton } from '@mildang/design-system/IconButton';
import Add from '@mildang/icons/react/add';
import { css } from '@mildang/styled-system/css';
/** 우측 사이드바 폭(260px)으로 실제 사용 맥락을 재현하는 컨테이너 */
const sidebar = css({
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
});
/** Panda 토큰 없는 데모 전용 사이즈는 inline style로 (스토리 스캐폴딩) */
const sidebarSize = { width: 260, height: 620 } as const;
const PropertyPanelEmptySectionExample = () => (
<div className={sidebar} style={sidebarSize}>
<PropertyPanel.Root aria-label="빈 섹션">
<PropertyPanel.Section
title="채우기"
actions={
<IconButton size="xs" variant="tertiary" aria-label="채우기 추가">
<Add />
</IconButton>
}
>
<PropertyPanel.EmptyHint>+ 버튼으로 채우기를 추가하세요</PropertyPanel.EmptyHint>
</PropertyPanel.Section>
</PropertyPanel.Root>
</div>
);
export default PropertyPanelEmptySectionExample;
비활성 필드
잠긴 좌표처럼 편집이 불가능한 필드를 보여주는 형태다.
코드
import { PropertyPanel } from '@mildang/design-system/unofficial/PropertyPanel';
import { Input } from '@mildang/design-system/Input';
import MoreHoriz from '@mildang/icons/react/more-horiz';
import { css } from '@mildang/styled-system/css';
/** 우측 사이드바 폭(260px)으로 실제 사용 맥락을 재현하는 컨테이너 */
const sidebar = css({
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
});
/** Panda 토큰 없는 데모 전용 사이즈는 inline style로 (스토리 스캐폴딩) */
const sidebarSize = { width: 260, height: 620 } as const;
const PropertyPanelDisabledFieldExample = () => (
<div className={sidebar} style={sidebarSize}>
<PropertyPanel.Root aria-label="비활성 입력">
<PropertyPanel.Section title="위치">
<PropertyPanel.Item label="좌표 (잠김)">
<Input size="sm" fullWidth startAdornment="X" defaultValue="120" disabled aria-label="X 좌표" />
<Input size="sm" fullWidth startAdornment="Y" defaultValue="64" disabled aria-label="Y 좌표" />
</PropertyPanel.Item>
<PropertyPanel.Item label="더보기">
<Input
size="sm"
fullWidth
startAdornment="#"
defaultValue="편집 불가"
disabled
endAdornment={<MoreHoriz />}
aria-label="읽기 전용 값"
/>
</PropertyPanel.Item>
</PropertyPanel.Section>
</PropertyPanel.Root>
</div>
);
export default PropertyPanelDisabledFieldExample;