Kanban
Layout
열 간 드래그 앤 드롭으로 카드를 옮기는 비공식 칸반 보드.
Usage
import
API Reference
예제
Usage
업무·과제를 상태별 열로 나눠 드래그로 옮기는 보드 열 내부 순서 변경과 빈 열 드롭까지 필요한 화면
import
import
import { Kanban } from '@mildang/design-system/unofficial/Kanban';예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
API Reference
Kanban Props
Prop
Type
Default
state
필수
UseKanbanReturn<TItem>
지정 안 함
onMove
(event: KanbanMoveEvent<TItem>) => void
지정 안 함
renderDragOverlay
(item: TItem) => ReactNode
지정 안 함
size
"sm" | "md" | "lg"
md
예제
기본 사용
백로그부터 완료까지 5개 열로 구성한 프로젝트 보드다. 담당자·마감일·진행률을 포함한 실사용 카드 형태를 보여준다.
import type { ComponentProps } from 'react';
import { Kanban, useKanban, type KanbanColumn, type KanbanItem } from '@mildang/design-system/unofficial/Kanban';
import { useState } from 'react';
import { IconButton } from '@mildang/design-system/IconButton';
import AddIcon from '@mildang/icons/react/add';
import MoreVertIcon from '@mildang/icons/react/more-vert';
import { Button } from '@mildang/design-system/Button';
import { Chip } from '@mildang/design-system/Chip';
import { Text } from '@mildang/design-system/Text';
import { ProgressBar } from '@mildang/design-system/Progress';
import { Avatar } from '@mildang/design-system/Avatar';
import CalendarIcon from '@mildang/icons/react/calendar';
import ChatIcon from '@mildang/icons/react/chat';
import { css } from '@mildang/styled-system/css';
type Priority = '긴급' | '높음' | '보통' | '낮음';
type Assignee = '김은지' | '박정우' | '오하늘' | '이서준';
const assigneeAvatar: Record<Assignee, string> = {
김은지: 'https://picsum.photos/seed/avatar1/100/100',
박정우: 'https://picsum.photos/seed/avatar2/100/100',
오하늘: 'https://picsum.photos/seed/avatar3/100/100',
이서준: 'https://picsum.photos/seed/avatar4/100/100',
};
interface Task extends KanbanItem {
title: string;
description: string;
priority: Priority;
assignee: Assignee;
dueDate?: string;
comments: number;
progress?: number;
category: string;
}
const columns: KanbanColumn[] = [
{ id: 'backlog', title: '백로그', tone: 'neutral' },
{ id: 'todo', title: '할 일', tone: 'info' },
{ id: 'progress', title: '진행 중', tone: 'warning' },
{ id: 'review', title: '검토', tone: 'error' },
{ id: 'done', title: '완료', tone: 'success' },
];
const initialTasks: Task[] = [
{
id: 'task-1',
columnId: 'backlog',
title: '학습 리포트 정보 구조 정리',
description: '교사와 학부모가 가장 먼저 확인할 핵심 지표를 재정의합니다.',
priority: '보통',
assignee: '김은지',
comments: 4,
category: '리서치',
},
{
id: 'task-2',
columnId: 'backlog',
title: '이벤트 네이밍 감사',
description: '분석 이벤트의 누락과 중복 속성을 점검합니다.',
priority: '낮음',
assignee: '박정우',
comments: 2,
category: '데이터',
},
{
id: 'task-3',
columnId: 'todo',
title: '대시보드 빈 상태 설계',
description: '데이터가 없을 때 다음 행동이 분명한 빈 상태를 설계합니다.',
priority: '높음',
assignee: '오하늘',
dueDate: '8월 12일',
comments: 7,
category: 'UX',
},
{
id: 'task-4',
columnId: 'todo',
title: 'Storybook 접근성 검사 연결',
description: '핵심 interaction story에 자동 접근성 검사를 추가합니다.',
priority: '보통',
assignee: '이서준',
dueDate: '8월 14일',
comments: 3,
category: '개발',
},
{
id: 'task-5',
columnId: 'progress',
title: '과제 생성 플로우 개선',
description: '반복 입력을 줄이고 저장 전 검토 단계를 명확히 합니다.',
priority: '긴급',
assignee: '김은지',
dueDate: '오늘',
comments: 12,
progress: 65,
category: 'UX',
},
{
id: 'task-6',
columnId: 'progress',
title: 'Kanban 키보드 이동 검증',
description: '포커스, 이동 안내, 취소 동작을 실제 브라우저에서 확인합니다.',
priority: '높음',
assignee: '박정우',
dueDate: '8월 11일',
comments: 5,
progress: 40,
category: '접근성',
},
{
id: 'task-7',
columnId: 'review',
title: '권한 설정 회귀 테스트',
description: '관리자 역할 조합별 읽기와 수정 권한을 검토합니다.',
priority: '긴급',
assignee: '오하늘',
dueDate: '오늘',
comments: 9,
progress: 90,
category: 'QA',
},
{
id: 'task-8',
columnId: 'done',
title: '디자인 토큰 문서화',
description: '컬러 역할과 컴포넌트 토큰 사용 예시를 정리했습니다.',
priority: '낮음',
assignee: '이서준',
comments: 6,
progress: 100,
category: '문서',
},
];
const boardFrameStyle = css({
w: '100%',
minW: 0,
p: '16',
bg: 'neutral.surface.low',
borderRadius: '8',
});
const cardHeaderStyle = css({
display: 'flex',
minW: 0,
alignItems: 'center',
justifyContent: 'space-between',
gap: '8',
});
const cardTitleStyle = css({ minW: 0, overflowWrap: 'anywhere' });
const cardMetaStyle = css({
display: 'flex',
alignItems: 'center',
gap: '6',
color: 'neutral.text.low',
textStyle: 'caption-lg',
'& svg': { w: '16', h: '16' },
});
const cardFooterStyle = css({
display: 'flex',
minH: '24',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8',
});
const cardFooterMetaStyle = css({ display: 'flex', alignItems: 'center', gap: '10' });
const priorityColor: Record<Priority, 'red' | 'orange' | 'blue' | 'neutral'> = {
긴급: 'red',
높음: 'orange',
보통: 'blue',
낮음: 'neutral',
};
function TaskCard({
task,
index,
}: {
task: Task;
index: number;
}) {
return (
<Kanban.Card
id={task.id}
index={index}
columnId={task.columnId}
textValue={task.title}
>
<Kanban.CardContent>
<div className={cardHeaderStyle}>
<Chip
label={task.priority}
color={priorityColor[task.priority]}
type="text"
size="sm"
/>
</div>
<Text as="strong" variant="title-md" color="primary.text.base" className={cardTitleStyle}>
{task.title}
</Text>
<Text variant="caption-lg" color="neutral.text.low">
{task.description}
</Text>
<Chip label={task.category} color="neutral" type="text" size="sm" />
{task.progress != null && (
<ProgressBar
value={task.progress}
size="sm"
color={task.progress === 100 ? 'green' : 'brand'}
labelPosition="right"
aria-label={`${task.title} 진행률`}
/>
)}
<div className={cardFooterStyle}>
<Avatar
size="xs"
src={assigneeAvatar[task.assignee]}
alt={task.assignee}
initial={task.assignee}
aria-label={`담당자 ${task.assignee}`}
/>
<div className={cardFooterMetaStyle}>
{task.dueDate && (
<span className={cardMetaStyle}>
<CalendarIcon aria-hidden />
{task.dueDate}
</span>
)}
<span className={cardMetaStyle}>
<ChatIcon aria-hidden />
{task.comments}
</span>
</div>
</div>
</Kanban.CardContent>
</Kanban.Card>
);
}
function Board({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const [items, setItems] = useState(initialTasks);
const kanban = useKanban({ items, onItemsChange: setItems });
return (
<div className={boardFrameStyle}>
<Kanban state={kanban} size={size} aria-label="제품 개발 업무 보드">
{columns.map((column) => {
const columnItems = kanban.getColumnItems(column.id);
const titleId = `kanban-${String(column.id)}`;
return (
<Kanban.Column key={String(column.id)} aria-labelledby={titleId}>
<Kanban.ColumnHeader>
<Kanban.ColumnIndicator tone={column.tone} />
<Kanban.ColumnTitle id={titleId}>{column.title}</Kanban.ColumnTitle>
<Kanban.ColumnCount aria-label={`${columnItems.length}개`}>
{columnItems.length}
</Kanban.ColumnCount>
<Kanban.ColumnActions>
<IconButton size="xs" variant="tertiary" aria-label={`${column.title}에 업무 추가`}>
<AddIcon />
</IconButton>
<IconButton size="xs" variant="tertiary" aria-label={`${column.title} 메뉴`}>
<MoreVertIcon />
</IconButton>
</Kanban.ColumnActions>
</Kanban.ColumnHeader>
<Kanban.ColumnBody>
<Kanban.ScrollShadow>
<Kanban.CardList
columnId={column.id}
items={columnItems}
aria-label={`${column.title} 업무`}
renderEmptyState={() => '여기에 카드를 놓으세요.'}
>
{(task, index) => (
<TaskCard key={String(task.id)} task={task} index={index} />
)}
</Kanban.CardList>
</Kanban.ScrollShadow>
<Button variant="secondary" size="sm" fullWidth startIcon={<AddIcon />}>
업무 추가
</Button>
</Kanban.ColumnBody>
</Kanban.Column>
);
})}
</Kanban>
</div>
);
}
const STORY_DEFAULT_ARGS = { ...({}), ...({ size: 'md' }) } as ComponentProps<typeof Kanban>;
const KanbanProjectBoardExampleRender = (args: ComponentProps<typeof Kanban>) => <Board size={args.size} />;
export default function KanbanProjectBoardExample(props: Partial<ComponentProps<typeof Kanban>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Kanban>;
return KanbanProjectBoardExampleRender(mergedProps);
}
Compact
size sm으로 카드 밀도를 높여 더 많은 카드를 한 화면에 보여준다.
코드
import { useState } from 'react';
import { Kanban, useKanban, type KanbanColumn, type KanbanItem } from '@mildang/design-system/unofficial/Kanban';
import { IconButton } from '@mildang/design-system/IconButton';
import AddIcon from '@mildang/icons/react/add';
import MoreVertIcon from '@mildang/icons/react/more-vert';
import { Button } from '@mildang/design-system/Button';
import { Chip } from '@mildang/design-system/Chip';
import { Text } from '@mildang/design-system/Text';
import { ProgressBar } from '@mildang/design-system/Progress';
import { Avatar } from '@mildang/design-system/Avatar';
import CalendarIcon from '@mildang/icons/react/calendar';
import ChatIcon from '@mildang/icons/react/chat';
import { css } from '@mildang/styled-system/css';
type Priority = '긴급' | '높음' | '보통' | '낮음';
type Assignee = '김은지' | '박정우' | '오하늘' | '이서준';
const assigneeAvatar: Record<Assignee, string> = {
김은지: 'https://picsum.photos/seed/avatar1/100/100',
박정우: 'https://picsum.photos/seed/avatar2/100/100',
오하늘: 'https://picsum.photos/seed/avatar3/100/100',
이서준: 'https://picsum.photos/seed/avatar4/100/100',
};
interface Task extends KanbanItem {
title: string;
description: string;
priority: Priority;
assignee: Assignee;
dueDate?: string;
comments: number;
progress?: number;
category: string;
}
const columns: KanbanColumn[] = [
{ id: 'backlog', title: '백로그', tone: 'neutral' },
{ id: 'todo', title: '할 일', tone: 'info' },
{ id: 'progress', title: '진행 중', tone: 'warning' },
{ id: 'review', title: '검토', tone: 'error' },
{ id: 'done', title: '완료', tone: 'success' },
];
const initialTasks: Task[] = [
{
id: 'task-1',
columnId: 'backlog',
title: '학습 리포트 정보 구조 정리',
description: '교사와 학부모가 가장 먼저 확인할 핵심 지표를 재정의합니다.',
priority: '보통',
assignee: '김은지',
comments: 4,
category: '리서치',
},
{
id: 'task-2',
columnId: 'backlog',
title: '이벤트 네이밍 감사',
description: '분석 이벤트의 누락과 중복 속성을 점검합니다.',
priority: '낮음',
assignee: '박정우',
comments: 2,
category: '데이터',
},
{
id: 'task-3',
columnId: 'todo',
title: '대시보드 빈 상태 설계',
description: '데이터가 없을 때 다음 행동이 분명한 빈 상태를 설계합니다.',
priority: '높음',
assignee: '오하늘',
dueDate: '8월 12일',
comments: 7,
category: 'UX',
},
{
id: 'task-4',
columnId: 'todo',
title: 'Storybook 접근성 검사 연결',
description: '핵심 interaction story에 자동 접근성 검사를 추가합니다.',
priority: '보통',
assignee: '이서준',
dueDate: '8월 14일',
comments: 3,
category: '개발',
},
{
id: 'task-5',
columnId: 'progress',
title: '과제 생성 플로우 개선',
description: '반복 입력을 줄이고 저장 전 검토 단계를 명확히 합니다.',
priority: '긴급',
assignee: '김은지',
dueDate: '오늘',
comments: 12,
progress: 65,
category: 'UX',
},
{
id: 'task-6',
columnId: 'progress',
title: 'Kanban 키보드 이동 검증',
description: '포커스, 이동 안내, 취소 동작을 실제 브라우저에서 확인합니다.',
priority: '높음',
assignee: '박정우',
dueDate: '8월 11일',
comments: 5,
progress: 40,
category: '접근성',
},
{
id: 'task-7',
columnId: 'review',
title: '권한 설정 회귀 테스트',
description: '관리자 역할 조합별 읽기와 수정 권한을 검토합니다.',
priority: '긴급',
assignee: '오하늘',
dueDate: '오늘',
comments: 9,
progress: 90,
category: 'QA',
},
{
id: 'task-8',
columnId: 'done',
title: '디자인 토큰 문서화',
description: '컬러 역할과 컴포넌트 토큰 사용 예시를 정리했습니다.',
priority: '낮음',
assignee: '이서준',
comments: 6,
progress: 100,
category: '문서',
},
];
const boardFrameStyle = css({
w: '100%',
minW: 0,
p: '16',
bg: 'neutral.surface.low',
borderRadius: '8',
});
const cardHeaderStyle = css({
display: 'flex',
minW: 0,
alignItems: 'center',
justifyContent: 'space-between',
gap: '8',
});
const cardTitleStyle = css({ minW: 0, overflowWrap: 'anywhere' });
const cardMetaStyle = css({
display: 'flex',
alignItems: 'center',
gap: '6',
color: 'neutral.text.low',
textStyle: 'caption-lg',
'& svg': { w: '16', h: '16' },
});
const cardFooterStyle = css({
display: 'flex',
minH: '24',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8',
});
const cardFooterMetaStyle = css({ display: 'flex', alignItems: 'center', gap: '10' });
const priorityColor: Record<Priority, 'red' | 'orange' | 'blue' | 'neutral'> = {
긴급: 'red',
높음: 'orange',
보통: 'blue',
낮음: 'neutral',
};
function TaskCard({
task,
index,
}: {
task: Task;
index: number;
}) {
return (
<Kanban.Card
id={task.id}
index={index}
columnId={task.columnId}
textValue={task.title}
>
<Kanban.CardContent>
<div className={cardHeaderStyle}>
<Chip
label={task.priority}
color={priorityColor[task.priority]}
type="text"
size="sm"
/>
</div>
<Text as="strong" variant="title-md" color="primary.text.base" className={cardTitleStyle}>
{task.title}
</Text>
<Text variant="caption-lg" color="neutral.text.low">
{task.description}
</Text>
<Chip label={task.category} color="neutral" type="text" size="sm" />
{task.progress != null && (
<ProgressBar
value={task.progress}
size="sm"
color={task.progress === 100 ? 'green' : 'brand'}
labelPosition="right"
aria-label={`${task.title} 진행률`}
/>
)}
<div className={cardFooterStyle}>
<Avatar
size="xs"
src={assigneeAvatar[task.assignee]}
alt={task.assignee}
initial={task.assignee}
aria-label={`담당자 ${task.assignee}`}
/>
<div className={cardFooterMetaStyle}>
{task.dueDate && (
<span className={cardMetaStyle}>
<CalendarIcon aria-hidden />
{task.dueDate}
</span>
)}
<span className={cardMetaStyle}>
<ChatIcon aria-hidden />
{task.comments}
</span>
</div>
</div>
</Kanban.CardContent>
</Kanban.Card>
);
}
function Board({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const [items, setItems] = useState(initialTasks);
const kanban = useKanban({ items, onItemsChange: setItems });
return (
<div className={boardFrameStyle}>
<Kanban state={kanban} size={size} aria-label="제품 개발 업무 보드">
{columns.map((column) => {
const columnItems = kanban.getColumnItems(column.id);
const titleId = `kanban-${String(column.id)}`;
return (
<Kanban.Column key={String(column.id)} aria-labelledby={titleId}>
<Kanban.ColumnHeader>
<Kanban.ColumnIndicator tone={column.tone} />
<Kanban.ColumnTitle id={titleId}>{column.title}</Kanban.ColumnTitle>
<Kanban.ColumnCount aria-label={`${columnItems.length}개`}>
{columnItems.length}
</Kanban.ColumnCount>
<Kanban.ColumnActions>
<IconButton size="xs" variant="tertiary" aria-label={`${column.title}에 업무 추가`}>
<AddIcon />
</IconButton>
<IconButton size="xs" variant="tertiary" aria-label={`${column.title} 메뉴`}>
<MoreVertIcon />
</IconButton>
</Kanban.ColumnActions>
</Kanban.ColumnHeader>
<Kanban.ColumnBody>
<Kanban.ScrollShadow>
<Kanban.CardList
columnId={column.id}
items={columnItems}
aria-label={`${column.title} 업무`}
renderEmptyState={() => '여기에 카드를 놓으세요.'}
>
{(task, index) => (
<TaskCard key={String(task.id)} task={task} index={index} />
)}
</Kanban.CardList>
</Kanban.ScrollShadow>
<Button variant="secondary" size="sm" fullWidth startIcon={<AddIcon />}>
업무 추가
</Button>
</Kanban.ColumnBody>
</Kanban.Column>
);
})}
</Kanban>
</div>
);
}
const KanbanCompactExample = () => <Board size="sm" />;
export default KanbanCompactExample;
Spacious
size lg로 카드 여백을 넓혀 정보 밀도를 낮춘 형태다.
코드
import { useState } from 'react';
import { Kanban, useKanban, type KanbanColumn, type KanbanItem } from '@mildang/design-system/unofficial/Kanban';
import { IconButton } from '@mildang/design-system/IconButton';
import AddIcon from '@mildang/icons/react/add';
import MoreVertIcon from '@mildang/icons/react/more-vert';
import { Button } from '@mildang/design-system/Button';
import { Chip } from '@mildang/design-system/Chip';
import { Text } from '@mildang/design-system/Text';
import { ProgressBar } from '@mildang/design-system/Progress';
import { Avatar } from '@mildang/design-system/Avatar';
import CalendarIcon from '@mildang/icons/react/calendar';
import ChatIcon from '@mildang/icons/react/chat';
import { css } from '@mildang/styled-system/css';
type Priority = '긴급' | '높음' | '보통' | '낮음';
type Assignee = '김은지' | '박정우' | '오하늘' | '이서준';
const assigneeAvatar: Record<Assignee, string> = {
김은지: 'https://picsum.photos/seed/avatar1/100/100',
박정우: 'https://picsum.photos/seed/avatar2/100/100',
오하늘: 'https://picsum.photos/seed/avatar3/100/100',
이서준: 'https://picsum.photos/seed/avatar4/100/100',
};
interface Task extends KanbanItem {
title: string;
description: string;
priority: Priority;
assignee: Assignee;
dueDate?: string;
comments: number;
progress?: number;
category: string;
}
const columns: KanbanColumn[] = [
{ id: 'backlog', title: '백로그', tone: 'neutral' },
{ id: 'todo', title: '할 일', tone: 'info' },
{ id: 'progress', title: '진행 중', tone: 'warning' },
{ id: 'review', title: '검토', tone: 'error' },
{ id: 'done', title: '완료', tone: 'success' },
];
const initialTasks: Task[] = [
{
id: 'task-1',
columnId: 'backlog',
title: '학습 리포트 정보 구조 정리',
description: '교사와 학부모가 가장 먼저 확인할 핵심 지표를 재정의합니다.',
priority: '보통',
assignee: '김은지',
comments: 4,
category: '리서치',
},
{
id: 'task-2',
columnId: 'backlog',
title: '이벤트 네이밍 감사',
description: '분석 이벤트의 누락과 중복 속성을 점검합니다.',
priority: '낮음',
assignee: '박정우',
comments: 2,
category: '데이터',
},
{
id: 'task-3',
columnId: 'todo',
title: '대시보드 빈 상태 설계',
description: '데이터가 없을 때 다음 행동이 분명한 빈 상태를 설계합니다.',
priority: '높음',
assignee: '오하늘',
dueDate: '8월 12일',
comments: 7,
category: 'UX',
},
{
id: 'task-4',
columnId: 'todo',
title: 'Storybook 접근성 검사 연결',
description: '핵심 interaction story에 자동 접근성 검사를 추가합니다.',
priority: '보통',
assignee: '이서준',
dueDate: '8월 14일',
comments: 3,
category: '개발',
},
{
id: 'task-5',
columnId: 'progress',
title: '과제 생성 플로우 개선',
description: '반복 입력을 줄이고 저장 전 검토 단계를 명확히 합니다.',
priority: '긴급',
assignee: '김은지',
dueDate: '오늘',
comments: 12,
progress: 65,
category: 'UX',
},
{
id: 'task-6',
columnId: 'progress',
title: 'Kanban 키보드 이동 검증',
description: '포커스, 이동 안내, 취소 동작을 실제 브라우저에서 확인합니다.',
priority: '높음',
assignee: '박정우',
dueDate: '8월 11일',
comments: 5,
progress: 40,
category: '접근성',
},
{
id: 'task-7',
columnId: 'review',
title: '권한 설정 회귀 테스트',
description: '관리자 역할 조합별 읽기와 수정 권한을 검토합니다.',
priority: '긴급',
assignee: '오하늘',
dueDate: '오늘',
comments: 9,
progress: 90,
category: 'QA',
},
{
id: 'task-8',
columnId: 'done',
title: '디자인 토큰 문서화',
description: '컬러 역할과 컴포넌트 토큰 사용 예시를 정리했습니다.',
priority: '낮음',
assignee: '이서준',
comments: 6,
progress: 100,
category: '문서',
},
];
const boardFrameStyle = css({
w: '100%',
minW: 0,
p: '16',
bg: 'neutral.surface.low',
borderRadius: '8',
});
const cardHeaderStyle = css({
display: 'flex',
minW: 0,
alignItems: 'center',
justifyContent: 'space-between',
gap: '8',
});
const cardTitleStyle = css({ minW: 0, overflowWrap: 'anywhere' });
const cardMetaStyle = css({
display: 'flex',
alignItems: 'center',
gap: '6',
color: 'neutral.text.low',
textStyle: 'caption-lg',
'& svg': { w: '16', h: '16' },
});
const cardFooterStyle = css({
display: 'flex',
minH: '24',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8',
});
const cardFooterMetaStyle = css({ display: 'flex', alignItems: 'center', gap: '10' });
const priorityColor: Record<Priority, 'red' | 'orange' | 'blue' | 'neutral'> = {
긴급: 'red',
높음: 'orange',
보통: 'blue',
낮음: 'neutral',
};
function TaskCard({
task,
index,
}: {
task: Task;
index: number;
}) {
return (
<Kanban.Card
id={task.id}
index={index}
columnId={task.columnId}
textValue={task.title}
>
<Kanban.CardContent>
<div className={cardHeaderStyle}>
<Chip
label={task.priority}
color={priorityColor[task.priority]}
type="text"
size="sm"
/>
</div>
<Text as="strong" variant="title-md" color="primary.text.base" className={cardTitleStyle}>
{task.title}
</Text>
<Text variant="caption-lg" color="neutral.text.low">
{task.description}
</Text>
<Chip label={task.category} color="neutral" type="text" size="sm" />
{task.progress != null && (
<ProgressBar
value={task.progress}
size="sm"
color={task.progress === 100 ? 'green' : 'brand'}
labelPosition="right"
aria-label={`${task.title} 진행률`}
/>
)}
<div className={cardFooterStyle}>
<Avatar
size="xs"
src={assigneeAvatar[task.assignee]}
alt={task.assignee}
initial={task.assignee}
aria-label={`담당자 ${task.assignee}`}
/>
<div className={cardFooterMetaStyle}>
{task.dueDate && (
<span className={cardMetaStyle}>
<CalendarIcon aria-hidden />
{task.dueDate}
</span>
)}
<span className={cardMetaStyle}>
<ChatIcon aria-hidden />
{task.comments}
</span>
</div>
</div>
</Kanban.CardContent>
</Kanban.Card>
);
}
function Board({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const [items, setItems] = useState(initialTasks);
const kanban = useKanban({ items, onItemsChange: setItems });
return (
<div className={boardFrameStyle}>
<Kanban state={kanban} size={size} aria-label="제품 개발 업무 보드">
{columns.map((column) => {
const columnItems = kanban.getColumnItems(column.id);
const titleId = `kanban-${String(column.id)}`;
return (
<Kanban.Column key={String(column.id)} aria-labelledby={titleId}>
<Kanban.ColumnHeader>
<Kanban.ColumnIndicator tone={column.tone} />
<Kanban.ColumnTitle id={titleId}>{column.title}</Kanban.ColumnTitle>
<Kanban.ColumnCount aria-label={`${columnItems.length}개`}>
{columnItems.length}
</Kanban.ColumnCount>
<Kanban.ColumnActions>
<IconButton size="xs" variant="tertiary" aria-label={`${column.title}에 업무 추가`}>
<AddIcon />
</IconButton>
<IconButton size="xs" variant="tertiary" aria-label={`${column.title} 메뉴`}>
<MoreVertIcon />
</IconButton>
</Kanban.ColumnActions>
</Kanban.ColumnHeader>
<Kanban.ColumnBody>
<Kanban.ScrollShadow>
<Kanban.CardList
columnId={column.id}
items={columnItems}
aria-label={`${column.title} 업무`}
renderEmptyState={() => '여기에 카드를 놓으세요.'}
>
{(task, index) => (
<TaskCard key={String(task.id)} task={task} index={index} />
)}
</Kanban.CardList>
</Kanban.ScrollShadow>
<Button variant="secondary" size="sm" fullWidth startIcon={<AddIcon />}>
업무 추가
</Button>
</Kanban.ColumnBody>
</Kanban.Column>
);
})}
</Kanban>
</div>
);
}
const KanbanSpaciousExample = () => <Board size="lg" />;
export default KanbanSpaciousExample;