shadcn ui 복합 컴포넌트 실습 Dialog Popover Carousel 접근성

··설명 약 7분·작성: 해비·블로그 소개

shadcn/ui의 Dialog, Popover, Carousel은 언제 사용하고 무엇을 확인하나요?

Dialog는 별도 대화 영역, Popover는 보조 정보, Carousel은 카드 탐색에 씁니다. 키보드 열기·닫기와 초점 복귀를 확인합니다. 본문은 Radix 기반 API이므로 다른 기반의 생성 파일과 구분해 읽습니다.

적용 조건·범위: 아래 코드는 앞 회차에서 선택한 Radix UI 기반 shadcn/ui 컴포넌트를 기준으로 합니다. 다른 기반으로 생성했다면 Trigger·Content 등의 실제 생성 API를 우선합니다.

본문 근거: Dialog · Popover · Carousel

컴포넌트 추가

npx shadcn@latest add dialog popover carousel

프로젝트에서 선택한 shadcn/ui preset에 따라 생성되는 내부 구현은 다를 수 있으므로 실제 생성된 컴포넌트 API를 함께 확인합니다.

Dialog

Dialog는 화면 위에 별도 대화 영역을 표시합니다. 중요한 확인이나 편집 UI에 사용할 수 있습니다.

<Dialog>
  <DialogTrigger asChild>
    <Button>열기</Button>
  </DialogTrigger>

  <DialogContent>
    <DialogHeader>
      <DialogTitle>작업 삭제</DialogTitle>
      <DialogDescription>
        삭제한 작업은 되돌릴 수 없습니다.
      </DialogDescription>
    </DialogHeader>
  </DialogContent>
</Dialog>

Popover

Popover는 현재 맥락을 유지한 채 작은 보조 콘텐츠를 보여줄 때 적합합니다.

<Popover>
  <PopoverTrigger asChild>
    <Button variant="outline">
      정보
    </Button>
  </PopoverTrigger>

  <PopoverContent>
    작업 상태를 변경할 수 있습니다.
  </PopoverContent>
</Popover>

Carousel은 언제 사용할까

여러 콘텐츠를 좌우로 탐색해야 하는 실제 요구가 있을 때 사용합니다. TaskFlow에는 현재 카드 슬라이더가 필요하지 않으므로 학습 예제에서만 다루고 누적 프로젝트에는 넣지 않습니다.

프로젝트를 발전시키는 과정에서 기술을 배웠다는 이유만으로 기능을 억지로 추가하지 않는 것도 중요합니다.

접근성 확인

복합 컴포넌트는 마우스로 열리는지만 확인하면 부족합니다.

  • Tab으로 Trigger에 접근 가능한가
  • Enter/Space로 열 수 있는가
  • Dialog가 열리면 포커스가 내부로 이동하는가
  • Escape로 닫히는가
  • 닫힌 후 포커스가 Trigger로 돌아오는가
  • 제목과 설명이 충분한가

자주 발생하는 오류와 해결

  • Trigger 안에 또 interactive 요소를 잘못 중첩
  • Dialog open 상태를 외부 state와 중복 관리
  • 포커스가 닫힌 뒤 사라짐
  • 시각적인 제목만 있고 DialogTitle이 없음
  • Popover를 중요한 확인 대화상자 용도로 사용

독립 실습: Dialog·Popover·Carousel을 별도 화면에서 조합하기

Dialog·Popover를 열고 Carousel 항목을 이동하세요.

예제 크게 보기 (새 탭)

예상 동작과 직접 확인: Tab·Enter로 Dialog를 열고 Escape로 닫은 뒤 설명 열기로 포커스가 돌아오는지 확인합니다. 도움말은 보조 정보, Carousel 다음 버튼은 두 번째 카드로 이동합니다. 모바일 터치와 키보드 포커스는 실제 기기에서 별도 확인합니다.

직접 실행: 환경 준비와 전체 파일

JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.

shadcn/ui 환경 준비의 설정만 적용한 별도 Vite 프로젝트에서 시작합니다. TaskFlow 기능은 필요 없습니다. 위 add 명령으로 Radix 기반 dialog·popover·carousel을 생성하고 Button이 없다면 npx shadcn@latest add button도 실행합니다.

src/App.tsx 전체 코드

import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel';
export default function App() {
  return (
    <main>
      <Dialog>
        <DialogTrigger asChild><Button>설명 열기</Button></DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>키보드 확인</DialogTitle>
            <DialogDescription>Escape로 닫고 포커스 복귀를 확인하세요.</DialogDescription>
          </DialogHeader>
        </DialogContent>
      </Dialog>
      <Popover>
        <PopoverTrigger asChild><Button>도움말</Button></PopoverTrigger>
        <PopoverContent>보조 정보입니다.</PopoverContent>
      </Popover>
      <div style={{ padding: '48px' }}>
        <Carousel aria-label="연습 카드">
          <CarouselContent>
            <CarouselItem>첫 번째 카드</CarouselItem>
            <CarouselItem>두 번째 카드</CarouselItem>
          </CarouselContent>
          <CarouselPrevious /><CarouselNext />
        </Carousel>
      </div>
    </main>
  );
}

변형 과제: DialogTitle과 DialogDescription이 사용자에게 각각 무엇을 알려 주는지 설명하고 실제 삭제 이벤트 없이 취소 흐름을 연습합니다.

학습 완료 체크리스트

  • Dialog와 Popover의 용도를 구분할 수 있습니다.
  • Trigger/Content 구조를 이해합니다.
  • 복합 UI의 키보드·포커스를 확인할 수 있습니다.
  • 필요하지 않은 컴포넌트를 프로젝트에 억지로 넣지 않습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.

삭제 버튼을 누르면 바로 삭제하지 않고 확인 Dialog를 띄웁니다. 별도의 정보 버튼에는 Popover를 사용합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 30단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: shadcn ui 시작 실습 Vite 설정 components json 입력 폼 Sonner의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json, src/components/TaskItem.tsx, src/components/ui/carousel.tsx, src/components/ui/dialog.tsx, src/components/ui/popover.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

독자 질문과 직접 확인

질문: 작업 삭제 확인 Dialog를 키보드만으로 열고 취소할 수 있나요?

컴포넌트 실습: step-30-shadcn-complex에서 Tab으로 삭제 버튼에 이동해 Enter 또는 Space로 Dialog를 엽니다. Escape로 닫고 다시 열어 취소·확인을 각각 시험합니다. Popover도 키보드로 열고 닫아 봅니다.

예상 화면·상태: 취소는 작업 목록을 유지하고 확인은 해당 작업만 삭제해야 합니다. 포커스 이동과 복귀는 실제 브라우저에서 직접 확인해야 할 항목입니다. 취소·Escape에는 원래 Trigger가 남아 있지만 삭제 확인 후에는 그 작업과 Trigger가 사라질 수 있으므로, 남아 있는 다음 조작 대상으로 키보드 탐색을 이어 갈 수 있는지도 별도로 기록합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: Dialog가 열리지 않으면 Trigger·Content 구성과 제어 상태를, 확인 전 삭제되면 삭제 이벤트가 확인 버튼에만 연결됐는지 점검합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 31회에서 Profiler로 렌더링 비용을 측정합니다.

완료 코드 ZIP 다운로드 — step-30-shadcn-complex

이전 체크포인트

step-29-shadcn-start

이번 목표

삭제 버튼을 누르면 바로 삭제하지 않고 확인 Dialog를 띄웁니다. 별도의 정보 버튼에는 Popover를 사용합니다.

Dialog를 제어형 open state로 바꾸면 삭제 후 자동 닫힘 등을 더 세밀하게 처리할 수 있습니다. 이번 단계에서는 기본 동작과 접근성 흐름을 먼저 확인합니다.

실행 확인

  • Tab 키로 각 버튼에 이동합니다.
  • Enter 또는 Space로 Dialog를 엽니다.
  • Escape로 닫힙니다.
  • Popover를 키보드로 열고 닫을 수 있습니다.
  • 삭제 확인 후 실제 작업이 제거됩니다.

완료 체크포인트:

step-30-shadcn-complex

전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.

전체 파일·코드 보기

components.json

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/index.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

실습 프로젝트 전체 코드

components.json

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/index.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

index.html

<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

package.json

{
  "name": "taskflow-step-30-shadcn-complex",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 127.0.0.1",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview --host 127.0.0.1"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "react-hook-form": "7.88.0",
    "react-router": "7.18.4",
    "@radix-ui/react-dialog": "1.1.23",
    "@radix-ui/react-popover": "1.1.23",
    "@radix-ui/react-slot": "1.3.3",
    "class-variance-authority": "0.7.1",
    "clsx": "2.1.1",
    "lucide-react": "1.47.0",
    "sonner": "2.0.8",
    "tailwind-merge": "3.7.0",
    "next-themes": "0.4.6",
    "embla-carousel-react": "8.6.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.0",
    "@vitejs/plugin-react": "6.1.1",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@types/node": "26.6.2",
    "@tailwindcss/vite": "4.3.3",
    "tailwindcss": "4.3.3",
    "tw-animate-css": "1.4.0"
  }
}

src/App.tsx

import {
  Navigate,
  Route,
  Routes,
} from 'react-router';
import AppLayout from './components/AppLayout';
import SettingsPage from './pages/SettingsPage';
import TaskDetailPage from './pages/TaskDetailPage';
import TaskListPage from './pages/TaskListPage';

export default function App() {
  return (
    <Routes>
      <Route element={<AppLayout />}>
        <Route
          index
          element={<Navigate to="/tasks" replace />}
        />
        <Route
          path="/tasks"
          element={<TaskListPage />}
        />
        <Route
          path="/tasks/:taskId"
          element={<TaskDetailPage />}
        />
        <Route
          path="/settings"
          element={<SettingsPage />}
        />
      </Route>
    </Routes>
  );
}

src/actions/saveProfile.ts

export type SaveProfileState = {
  message: string;
};

export async function saveProfile(
  _previousState: SaveProfileState,
  formData: FormData,
): Promise<SaveProfileState> {
  const displayName = String(
    formData.get('displayName') ?? '',
  ).trim();

  await new Promise((resolve) =>
    setTimeout(resolve, 500),
  );

  if (!displayName) {
    return {
      message: '이름을 입력해 주세요.',
    };
  }

  return {
    message: '프로필을 저장했습니다.',
  };
}

src/actions/updateTask.ts

export async function updateTaskOnServer(
  taskId: string,
): Promise<void> {
  await new Promise((resolve) =>
    setTimeout(resolve, 600),
  );

  if (!taskId || taskId === 'fail') {
    throw new Error('작업 ID가 없습니다.');
  }
}

src/components/AppLayout.tsx

import {
  NavLink,
  Outlet,
} from 'react-router';
import { Toaster } from '@/components/ui/sonner';
import Header from './Header';

export default function AppLayout() {
  return (
    <main className="app">
      <Header />

      <nav aria-label="주요 메뉴">
        <NavLink to="/tasks">작업</NavLink>
        {' · '}
        <NavLink to="/settings">설정</NavLink>
      </nav>

      <Outlet />
      <Toaster />
    </main>
  );
}

src/components/AsyncDemo.tsx

import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
 const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
 return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
 <OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}

src/components/Header.tsx

export default function Header() {
  return (
    <header>
      <p className="eyebrow">React Learning Project</p>
      <h1>TaskFlow</h1>
      <p>오늘 할 일을 하나씩 정리해 보세요.</p>
    </header>
  );
}

src/components/NetworkStatus.tsx

import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
 const [showDebugMessage, setShowDebugMessage] = useState(false);
 const isOnline = useNetworkStatus(showDebugMessage);
 return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
 <label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}

src/components/OptimisticTaskList.tsx

import {
  useTransition,
  useOptimistic,
  useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';

type OptimisticTaskListProps = {
  tasks: Task[];
  onCommitToggle: (taskId: string) => void;
};

export default function OptimisticTaskList({
  tasks,
  onCommitToggle,
}: OptimisticTaskListProps) {
  const [isPending, startTransition] = useTransition();
  const [errorMessage, setErrorMessage] =
    useState('');

  const [optimisticTasks, toggleOptimistic] =
    useOptimistic(
      tasks,
      (currentTasks, taskId: string) =>
        currentTasks.map((task) =>
          task.id === taskId
            ? {
                ...task,
                completed: !task.completed,
              }
            : task,
        ),
    );

  const handleToggle = (taskId: string) => {
    setErrorMessage('');

    startTransition(async () => {
      toggleOptimistic(taskId);

      try {
        await updateTaskOnServer(taskId);
        startTransition(() => onCommitToggle(taskId));
      } catch {
        setErrorMessage(
          '저장에 실패했습니다. 기존 상태로 돌아갑니다.',
        );
      }
    });
  };

  return (
    <section>
      {errorMessage ? (
        <p role="alert">{errorMessage}</p>
      ) : null}

      <ul>
        {optimisticTasks.map((task) => (
          <li key={task.id}>
            <button
              type="button"
              disabled={isPending}
              onClick={() => handleToggle(task.id)}
            >
              {task.completed ? '완료 취소' : '완료'}
            </button>
            <span>{task.title}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}

src/components/ProfileSaveForm.tsx

import { useActionState } from 'react';
import {
  saveProfile,
  type SaveProfileState,
} from '../actions/saveProfile';

const initialState: SaveProfileState = {
  message: '',
};

export default function ProfileSaveForm() {
  const [state, formAction, isPending] =
    useActionState(saveProfile, initialState);

  return (
    <form action={formAction}>
      <label>
        표시 이름
        <input name="displayName" />
      </label>

      <button disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>

      <p>{state.message}</p>
    </form>
  );
}

src/components/StateResetDemo.tsx

import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
 { id: 'example-1', title: '첫 번째 작업', completed: false },
 { id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
 const [selectedId, setSelectedId] = useState(examples[0].id);
 const selectedTask = examples.find(task => task.id === selectedId)!;
 return <details><summary>state 초기화 실습</summary>
 <p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
 {examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
 <TaskEditor key={selectedTask.id} task={selectedTask} />
 </details>;
}

src/components/StatsSkeleton.tsx

export default function StatsSkeleton() {
  return (
    <section aria-busy="true">
      <h2>작업 통계</h2>
      <p>통계를 불러오는 중...</p>
    </section>
  );
}

src/components/TaskCard.tsx

import type { ReactNode } from 'react';

type TaskCardProps = {
  children: ReactNode;
  actions?: ReactNode;
};

export default function TaskCard({
  children,
  actions,
}: TaskCardProps) {
  return (
    <li className="task-card">
      <div className="task-card__content">
        {children}
      </div>
      {actions ? (
        <div className="task-card__actions">
          {actions}
        </div>
      ) : null}
    </li>
  );
}

src/components/TaskEditor.tsx

import { useState } from 'react';
import type { Task } from '../types/task';

type TaskEditorProps = {
  task: Task;
};

export default function TaskEditor({
  task,
}: TaskEditorProps) {
  const [title, setTitle] = useState(task.title);

  return (
    <section>
      <h2>작업 편집</h2>
      <label htmlFor="editor-title">편집 입력 연습</label>
      <input id="editor-title"
        value={title}
        onChange={(event) => setTitle(event.target.value)}
      />
      <p>선택한 작업 ID: {task.id}</p>
    </section>
  );
}

src/components/TaskFilter.tsx

import type {
  Dispatch,
  SetStateAction,
} from 'react';
import type { TaskStatusFilter } from '../types/task';

type TaskFilterProps = {
  keyword: string;
  onKeywordChange: Dispatch<SetStateAction<string>>;
  status: TaskStatusFilter;
  onStatusChange: Dispatch<
    SetStateAction<TaskStatusFilter>
  >;
  onReset: () => void;
};

export default function TaskFilter({
  keyword,
  onKeywordChange,
  status,
  onStatusChange,
  onReset,
}: TaskFilterProps) {
  return (
    <section>
      <label htmlFor="task-keyword">
        작업 검색
      </label>

      <input
        id="task-keyword"
        value={keyword}
        onChange={(event) =>
          onKeywordChange(event.target.value)
        }
      />

      <label htmlFor="task-status">
        상태
      </label>

      <select
        id="task-status"
        value={status}
        onChange={(event) =>
          onStatusChange(
            event.target.value as TaskStatusFilter,
          )
        }
      >
        <option value="all">전체</option>
        <option value="active">진행 중</option>
        <option value="completed">완료</option>
      </select>

      <button type="button" onClick={onReset}>
        필터 초기화
      </button>
    </section>
  );
}

src/components/TaskForm.tsx

import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTaskContext } from '@/context/TaskContext';

type TaskFormValues = {
  title: string;
};

export default function TaskForm() {
  const { dispatch } = useTaskContext();

  const {
    register,
    handleSubmit,
    setValue,
    setFocus,
    formState: { errors },
  } = useForm<TaskFormValues>({
    defaultValues: {
      title: '',
    },
  });

  const onSubmit = (values: TaskFormValues) => {
    const title = values.title.trim();

    dispatch({
      type: 'task/added',
      task: {
        id: crypto.randomUUID(),
        title,
        completed: false,
      },
    });

    setValue('title', '');
    setFocus('title');
    toast.success('작업이 추가되었습니다.');
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="space-y-2"
      noValidate
    >
      <label htmlFor="task-title">
        새 작업
      </label>

      <Input
        id="task-title"
        aria-invalid={Boolean(errors.title)}
        aria-describedby={errors.title ? "task-title-error" : undefined}
        {...register('title', {
          setValueAs: (value: string) => value.trim(),
          required: '작업 제목을 입력해 주세요.',
          minLength: {
            value: 2,
            message: '2자 이상 입력해 주세요.',
          },
        })}
      />

      {errors.title && (
        <p id="task-title-error" role="alert">
          {errors.title.message}
        </p>
      )}

      <Button type="submit">
        추가
      </Button>
    </form>
  );
}

src/components/TaskItem.tsx

import TaskCard from './TaskCard';
import { Link } from 'react-router';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { useTaskContext } from '@/context/TaskContext';
import type { Task } from '@/types/task';

type TaskItemProps = {
  task: Task;
};

export default function TaskItem({
  task,
}: TaskItemProps) {
  const { dispatch } = useTaskContext();

  const handleDelete = () => {
    dispatch({
      type: 'task/deleted',
      id: task.id,
    });
  };

  return (
    <TaskCard>
      <Button
        type="button"
        variant="outline"
        onClick={() =>
          dispatch({
            type: 'task/toggled',
            id: task.id,
          })
        }
      >
        {task.completed ? '완료 취소' : '완료'}
      </Button>

      <span>{task.title}</span>

      <Link to={`/tasks/${task.id}`}>
        상세
      </Link>

      <Popover>
        <PopoverTrigger asChild>
          <Button type="button" variant="outline">
            정보
          </Button>
        </PopoverTrigger>
        <PopoverContent>
          현재 상태: {task.completed ? '완료' : '진행 중'}
        </PopoverContent>
      </Popover>

      <Dialog>
        <DialogTrigger asChild>
          <Button type="button" variant="destructive">
            삭제
          </Button>
        </DialogTrigger>

        <DialogContent>
          <DialogHeader>
            <DialogTitle>
              이 작업을 삭제할까요?
            </DialogTitle>
            <DialogDescription>
              삭제한 작업은 현재 예제에서 복구할 수 없습니다.
            </DialogDescription>
          </DialogHeader>

          <DialogFooter>
            <Button
              type="button"
              variant="destructive"
              onClick={handleDelete}
            >
              삭제 확인
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </TaskCard>
  );
}

src/components/TaskList.tsx

import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
import TaskItem from './TaskItem';


type TaskListProps = {
  tasks: Task[];
};


export default function TaskList({
  tasks,
}: TaskListProps) {
  const { tasks: allTasks } = useTaskContext();


  if (allTasks.length === 0) {
    return <p>등록된 작업이 없습니다.</p>;
  }


  if (tasks.length === 0) {
    return <p>조건에 맞는 작업이 없습니다.</p>;
  }


  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}

src/components/TaskStats.tsx

import type { Task } from '../types/task';

type TaskStatsProps = {
  tasks: Task[];
};

export default function TaskStats({
  tasks,
}: TaskStatsProps) {
  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  return (
    <section>
      <h2>작업 통계</h2>
      <p>전체: {tasks.length}</p>
      <p>완료: {completedCount}</p>
      <p>진행 중: {tasks.length - completedCount}</p>
    </section>
  );
}

src/components/TaskSummary.tsx

type TaskSummaryProps = {
  total: number;
  completed: number;
  remaining: number;
  label?: string;
};

export default function TaskSummary({
  total,
  completed,
  remaining,
  label = '작업 현황',
}: TaskSummaryProps) {
  return (
    <section aria-label={label}>
      <p>
        전체 {total} · 완료 {completed} · 남음 {remaining}
      </p>
    </section>
  );
}

src/components/ui/button.tsx

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline:
          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

src/components/ui/carousel.tsx

"use client"

import * as React from "react"
import useEmblaCarousel, {
  type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]

type CarouselProps = {
  opts?: CarouselOptions
  plugins?: CarouselPlugin
  orientation?: "horizontal" | "vertical"
  setApi?: (api: CarouselApi) => void
}

type CarouselContextProps = {
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
  api: ReturnType<typeof useEmblaCarousel>[1]
  scrollPrev: () => void
  scrollNext: () => void
  canScrollPrev: boolean
  canScrollNext: boolean
} & CarouselProps

const CarouselContext = React.createContext<CarouselContextProps | null>(null)

function useCarousel() {
  const context = React.useContext(CarouselContext)

  if (!context) {
    throw new Error("useCarousel must be used within a <Carousel />")
  }

  return context
}

const Carousel = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
  (
    {
      orientation = "horizontal",
      opts,
      setApi,
      plugins,
      className,
      children,
      ...props
    },
    ref
  ) => {
    const [carouselRef, api] = useEmblaCarousel(
      {
        ...opts,
        axis: orientation === "horizontal" ? "x" : "y",
      },
      plugins
    )
    const [canScrollPrev, setCanScrollPrev] = React.useState(false)
    const [canScrollNext, setCanScrollNext] = React.useState(false)

    const onSelect = React.useCallback((api: CarouselApi) => {
      if (!api) {
        return
      }

      setCanScrollPrev(api.canScrollPrev())
      setCanScrollNext(api.canScrollNext())
    }, [])

    const scrollPrev = React.useCallback(() => {
      api?.scrollPrev()
    }, [api])

    const scrollNext = React.useCallback(() => {
      api?.scrollNext()
    }, [api])

    const handleKeyDown = React.useCallback(
      (event: React.KeyboardEvent<HTMLDivElement>) => {
        if (event.key === "ArrowLeft") {
          event.preventDefault()
          scrollPrev()
        } else if (event.key === "ArrowRight") {
          event.preventDefault()
          scrollNext()
        }
      },
      [scrollPrev, scrollNext]
    )

    React.useEffect(() => {
      if (!api || !setApi) {
        return
      }

      setApi(api)
    }, [api, setApi])

    React.useEffect(() => {
      if (!api) {
        return
      }

      onSelect(api)
      api.on("reInit", onSelect)
      api.on("select", onSelect)

      return () => {
        api?.off("select", onSelect)
      }
    }, [api, onSelect])

    return (
      <CarouselContext.Provider
        value={{
          carouselRef,
          api: api,
          opts,
          orientation:
            orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
          scrollPrev,
          scrollNext,
          canScrollPrev,
          canScrollNext,
        }}
      >
        <div
          ref={ref}
          onKeyDownCapture={handleKeyDown}
          className={cn("relative", className)}
          role="region"
          aria-roledescription="carousel"
          {...props}
        >
          {children}
        </div>
      </CarouselContext.Provider>
    )
  }
)
Carousel.displayName = "Carousel"

const CarouselContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { carouselRef, orientation } = useCarousel()

  return (
    <div ref={carouselRef} className="overflow-hidden">
      <div
        ref={ref}
        className={cn(
          "flex",
          orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
          className
        )}
        {...props}
      />
    </div>
  )
})
CarouselContent.displayName = "CarouselContent"

const CarouselItem = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { orientation } = useCarousel()

  return (
    <div
      ref={ref}
      role="group"
      aria-roledescription="slide"
      className={cn(
        "min-w-0 shrink-0 grow-0 basis-full",
        orientation === "horizontal" ? "pl-4" : "pt-4",
        className
      )}
      {...props}
    />
  )
})
CarouselItem.displayName = "CarouselItem"

const CarouselPrevious = React.forwardRef<
  HTMLButtonElement,
  React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
  const { orientation, scrollPrev, canScrollPrev } = useCarousel()

  return (
    <Button
      ref={ref}
      variant={variant}
      size={size}
      className={cn(
        "absolute  h-8 w-8 rounded-full",
        orientation === "horizontal"
          ? "-left-12 top-1/2 -translate-y-1/2"
          : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollPrev}
      onClick={scrollPrev}
      {...props}
    >
      <ArrowLeft className="h-4 w-4" />
      <span className="sr-only">Previous slide</span>
    </Button>
  )
})
CarouselPrevious.displayName = "CarouselPrevious"

const CarouselNext = React.forwardRef<
  HTMLButtonElement,
  React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
  const { orientation, scrollNext, canScrollNext } = useCarousel()

  return (
    <Button
      ref={ref}
      variant={variant}
      size={size}
      className={cn(
        "absolute h-8 w-8 rounded-full",
        orientation === "horizontal"
          ? "-right-12 top-1/2 -translate-y-1/2"
          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollNext}
      onClick={scrollNext}
      {...props}
    >
      <ArrowRight className="h-4 w-4" />
      <span className="sr-only">Next slide</span>
    </Button>
  )
})
CarouselNext.displayName = "CarouselNext"

export {
  type CarouselApi,
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
}

src/components/ui/dialog.tsx

"use client"

import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
      className
    )}
    {...props}
  />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

const DialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-1.5 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
DialogHeader.displayName = "DialogHeader"

const DialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
DialogFooter.displayName = "DialogFooter"

const DialogTitle = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn(
      "text-lg font-semibold leading-none tracking-tight",
      className
    )}
    {...props}
  />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName

const DialogDescription = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName

export {
  Dialog,
  DialogPortal,
  DialogOverlay,
  DialogTrigger,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
}

src/components/ui/input.tsx

import * as React from "react"

import { cn } from "@/lib/utils"

const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Input.displayName = "Input"

export { Input }

src/components/ui/popover.tsx

"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const PopoverAnchor = PopoverPrimitive.Anchor

const PopoverContent = React.forwardRef<
  React.ElementRef<typeof PopoverPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
  <PopoverPrimitive.Portal>
    <PopoverPrimitive.Content
      ref={ref}
      align={align}
      sideOffset={sideOffset}
      className={cn(
        "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
        className
      )}
      {...props}
    />
  </PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

src/components/ui/sonner.tsx

"use client"

import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"

type ToasterProps = React.ComponentProps<typeof Sonner>

const Toaster = ({ ...props }: ToasterProps) => {
  const { theme = "system" } = useTheme()

  return (
    <Sonner
      theme={theme as ToasterProps["theme"]}
      className="toaster group"
      toastOptions={{
        classNames: {
          toast:
            "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
          description: "group-[.toast]:text-muted-foreground",
          actionButton:
            "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
          cancelButton:
            "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
        },
      }}
      {...props}
    />
  )
}

export { Toaster }

src/context/TaskContext.tsx

import {
  createContext,
  useContext,
  useReducer,
  type Dispatch,
  type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
  Task,
  TaskAction,
} from '../types/task';

type TaskContextValue = {
  tasks: Task[];
  dispatch: Dispatch<TaskAction>;
};

const TaskContext = createContext<
  TaskContextValue | undefined
>(undefined);

const initialTasks: Task[] = [
  {
    id: 'task-1',
    title: 'Reducer와 Context 연결하기',
    completed: false,
  },
];

export function TaskProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [tasks, dispatch] = useReducer(
    taskReducer,
    initialTasks,
  );

  return (
    <TaskContext.Provider
      value={{ tasks, dispatch }}
    >
      {children}
    </TaskContext.Provider>
  );
}

export function useTaskContext() {
  const value = useContext(TaskContext);

  if (!value) {
    throw new Error(
      'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
    );
  }

  return value;
}

src/hooks/useNetworkStatus.ts

import {
  useEffect,
  useEffectEvent,
  useState,
} from 'react';

export function useNetworkStatus(
  showDebugMessage: boolean,
) {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine,
  );

  const onOnline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 온라인 상태입니다.');
    }

    setIsOnline(true);
  });

  const onOffline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 오프라인 상태입니다.');
    }

    setIsOnline(false);
  });

  useEffect(() => {
    const handleOnline = () => onOnline();
    const handleOffline = () => onOffline();

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener(
        'online',
        handleOnline,
      );
      window.removeEventListener(
        'offline',
        handleOffline,
      );
    };
  }, []);

  return isOnline;
}

src/hooks/useTaskFilter.ts

import {
  useDeferredValue,
  useState,
  useTransition,
  type Dispatch,
  type SetStateAction,
} from 'react';
import type {
  Task,
  TaskStatusFilter,
} from '../types/task';

export function useTaskFilter(tasks: Task[]) {
  const [keyword, setKeyword] = useState('');
  const [status, setStatusState] =
    useState<TaskStatusFilter>('all');
  const [isPending, startTransition] =
    useTransition();

  const deferredKeyword =
    useDeferredValue(keyword);

  const normalizedKeyword = deferredKeyword
    .trim()
    .toLowerCase();

  const visibleTasks = tasks.filter((task) => {
    const matchesKeyword = task.title
      .toLowerCase()
      .includes(normalizedKeyword);

    const matchesStatus =
      status === 'all' ||
      (status === 'active' && !task.completed) ||
      (status === 'completed' && task.completed);

    return matchesKeyword && matchesStatus;
  });

  const setStatus: Dispatch<
    SetStateAction<TaskStatusFilter>
  > = (nextStatus) => {
    startTransition(() => {
      setStatusState(nextStatus);
    });
  };

  const resetFilter = () => {
    setKeyword('');
    startTransition(() => {
      setStatusState('all');
    });
  };

  return {
    keyword,
    setKeyword,
    status,
    setStatus,
    visibleTasks,
    resetFilter,
    isStale: keyword !== deferredKeyword,
    isPending,
  };
}

src/hooks/useTaskStats.ts

import { useTaskContext } from '../context/TaskContext';

export function useTaskStats() {
  const { tasks } = useTaskContext();

  const totalCount = tasks.length;

  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  const remainingCount =
    totalCount - completedCount;

  return {
    totalCount,
    completedCount,
    remainingCount,
  };
}

src/index.css

@import "tailwindcss";
@import "tw-animate-css";
@theme inline {
 --color-background: var(--background); --color-foreground: var(--foreground);
 --color-card: var(--background); --color-card-foreground: var(--foreground);
 --color-popover: var(--background); --color-popover-foreground: var(--foreground);
 --color-primary: #171717; --color-primary-foreground: #fff;
 --color-secondary: #eee; --color-secondary-foreground: #171717;
 --color-muted: #eee; --color-muted-foreground: #555;
 --color-accent: #eee; --color-accent-foreground: #171717;
 --color-destructive: #b91c1c; --color-destructive-foreground: #fff;
 --color-border: #aaa; --color-input: #aaa; --color-ring: #555;
 --radius-sm: 0px; --radius-md: 0px; --radius-lg: 0px;
}
:root { --background: #fff; --foreground: #111; }
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, sans-serif;
  color: #111;
  background: #f5f5f5;
}

.app {
  width: min(720px, calc(100% - 32px));
  margin: 48px auto;
}

.eyebrow {
  color: #666;
  font-size: 14px;
}

ul {
  padding-left: 20px;
}

button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }

.rounded-lg,.rounded-md,.rounded-sm { border-radius: 0; } .shadow-lg,.shadow-md,.shadow-sm { box-shadow:none; }

src/lib/utils.ts

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TaskProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </TaskProvider>
  </StrictMode>,
);

src/pages/SettingsPage.tsx

import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';

export default function SettingsPage() {
  return (
    <section>
      <h2>설정</h2>
      <p>TaskFlow 설정 화면입니다.</p>
      <ProfileSaveForm /><AsyncDemo />
    </section>
  );
}

src/pages/TaskDetailPage.tsx

import {
  Link,
  useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';

export default function TaskDetailPage() {
  const { taskId } = useParams();
  const { tasks } = useTaskContext();

  const task = tasks.find(
    (item) => item.id === taskId,
  );

  if (!task) {
    return (
      <section>
        <h2>작업을 찾을 수 없습니다.</h2>
        <Link to="/tasks">
          목록으로 돌아가기
        </Link>
      </section>
    );
  }

  return (
    <section>
      <h2>{task.title}</h2>
      <p>
        상태: {task.completed ? '완료' : '진행 중'}
      </p>
      <Link to="/tasks">
        목록으로 돌아가기
      </Link>
    </section>
  );
}

src/pages/TaskListPage.tsx

import { useEffect, lazy, Suspense } from 'react';
import TaskForm from '../components/TaskForm';
import TaskList from '../components/TaskList';
import TaskFilter from '../components/TaskFilter';
import StateResetDemo from '../components/StateResetDemo';
import NetworkStatus from '../components/NetworkStatus';
import TaskSummary from '../components/TaskSummary';
import { useTaskContext } from '../context/TaskContext';
import { useTaskStats } from '../hooks/useTaskStats';
import { useTaskFilter } from '../hooks/useTaskFilter';
import StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
export default function TaskListPage() {
 const { tasks } = useTaskContext();
 const { totalCount, completedCount, remainingCount } = useTaskStats();
 const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
 useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
 return <section>
 <h2>작업</h2>
 <NetworkStatus />
 <TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
 <TaskForm />
 <TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
 <p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
 <TaskList tasks={visibleTasks} />
 <Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
 <StateResetDemo />
 
 </section>;
}

src/reducers/taskReducer.ts

import type {
  Task,
  TaskAction,
} from '../types/task';

export function taskReducer(
  tasks: Task[],
  action: TaskAction,
): Task[] {
  switch (action.type) {
    case 'task/added':
      return [...tasks, action.task];

    case 'task/toggled':
      return tasks.map((task) =>
        task.id === action.id
          ? { ...task, completed: !task.completed }
          : task,
      );

    case 'task/deleted':
      return tasks.filter(
        (task) => task.id !== action.id,
      );

    default:
      return tasks;
  }
}

src/types/task.ts

export type TaskId = string;

export type Task = {
  id: TaskId;
  title: string;
  completed: boolean;
};

export type TaskAction =
  | {
      type: 'task/added';
      task: Task;
    }
  | {
      type: 'task/toggled';
      id: TaskId;
    }
  | {
      type: 'task/deleted';
      id: TaskId;
    };

export type TaskStatusFilter = 'all' | 'active' | 'completed';

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "types": [
      "vite/client"
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "src",
    "vite.config.ts"
  ]
}

vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } });

글 소개·학습 준비·전체 목차

shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성 학습 표지

React · 30/32 · TaskFlow 누적 실습

shadcn ui 복합 컴포넌트 실습 Dialog Popover Carousel 접근성

React · 30 / 32 · 필수 학습 · shadcn/ui 복합 컴포넌트

Dialog는 별도 대화 영역을, Popover는 현재 맥락의 보조 정보를, Carousel은 여러 카드의 순차 탐색을 구성합니다. Trigger와 Content를 조합한 뒤 키보드로 열기·닫기·초점 복귀를 확인해야 사용할 수 있는 UI인지 판단할 수 있습니다.

이 글에서는 각 컴포넌트의 조합 방식을 먼저 독립적으로 설명하고, TaskFlow에는 실제로 필요한 Dialog와 Popover만 적용합니다. Carousel은 사용법과 선택 기준을 학습하지만 프로젝트에 억지로 넣지 않습니다.

아래 코드는 앞 회차에서 선택한 Radix UI 기반 shadcn/ui 컴포넌트를 기준으로 합니다. 다른 기반으로 생성했다면 Trigger·Content 등의 실제 생성 API를 우선합니다.

이번 글에서 배우는 내용

  • Dialog 구조
  • Popover 구조
  • Carousel을 사용할 때의 기준
  • Trigger와 Content
  • 포커스 이동
  • Escape·키보드 조작
  • 복합 UI의 접근성 확인

목차

다음 학습

기능 추가는 여기까지입니다. 다음 글에서는 새로운 최적화를 바로 적용하지 않고 React DevTools Profiler로 실제 렌더링 횟수와 비용을 측정합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 33개 · 필수 27개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기