shadcn/ui의 Dialog, Popover, Carousel은 언제 사용하고 무엇을 확인하나요?
Dialog는 별도 대화 영역, Popover는 보조 정보, Carousel은 카드 탐색에 씁니다. 키보드 열기·닫기와 초점 복귀를 확인합니다. 본문은 Radix 기반 API이므로 다른 기반의 생성 파일과 구분해 읽습니다.
적용 조건·범위: 아래 코드는 앞 회차에서 선택한 Radix UI 기반 shadcn/ui 컴포넌트를 기준으로 합니다. 다른 기반으로 생성했다면 Trigger·Content 등의 실제 생성 API를 우선합니다.
컴포넌트 추가
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)) } } });
글 소개·학습 준비·전체 목차

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개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.