Kanban

Layout

열 간 드래그 앤 드롭으로 카드를 옮기는 비공식 칸반 보드.

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;