1216 lines
32 KiB
Markdown
1216 lines
32 KiB
Markdown
|
|
# shadcn/ui 완전 구현 가이드
|
|||
|
|
|
|||
|
|
> 이 문서는 shadcn/ui 공식 문서(https://ui.shadcn.com)를 철저히 분석하여 작성되었습니다.
|
|||
|
|
|
|||
|
|
## 목차
|
|||
|
|
|
|||
|
|
1. [설치 및 초기 설정](#1-설치-및-초기-설정)
|
|||
|
|
2. [CSS 변수 및 테마 설정](#2-css-변수-및-테마-설정)
|
|||
|
|
3. [컴포넌트별 구현 가이드](#3-컴포넌트별-구현-가이드)
|
|||
|
|
4. [문제 해결 (Troubleshooting)](#4-문제-해결-troubleshooting)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 설치 및 초기 설정
|
|||
|
|
|
|||
|
|
### 1.1 필수 패키지 설치
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm install tailwindcss@latest
|
|||
|
|
npm install class-variance-authority clsx tailwind-merge
|
|||
|
|
npm install @radix-ui/react-* # 필요한 Radix UI 컴포넌트들
|
|||
|
|
npm install lucide-react # 아이콘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.2 Tailwind CSS 설정
|
|||
|
|
|
|||
|
|
**중요**: shadcn/ui는 Tailwind CSS v4를 사용할 수 있지만, 기본적으로 v3 스타일을 따릅니다.
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
// tailwind.config.js (v3 스타일)
|
|||
|
|
module.exports = {
|
|||
|
|
darkMode: ["class"],
|
|||
|
|
content: [
|
|||
|
|
"./pages/**/*.{ts,tsx}",
|
|||
|
|
"./components/**/*.{ts,tsx}",
|
|||
|
|
"./app/**/*.{ts,tsx}",
|
|||
|
|
"./src/**/*.{ts,tsx}",
|
|||
|
|
],
|
|||
|
|
theme: {
|
|||
|
|
container: {
|
|||
|
|
center: true,
|
|||
|
|
padding: "2rem",
|
|||
|
|
screens: {
|
|||
|
|
"2xl": "1400px",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
extend: {
|
|||
|
|
colors: {
|
|||
|
|
border: "hsl(var(--border))",
|
|||
|
|
input: "hsl(var(--input))",
|
|||
|
|
ring: "hsl(var(--ring))",
|
|||
|
|
background: "hsl(var(--background))",
|
|||
|
|
foreground: "hsl(var(--foreground))",
|
|||
|
|
primary: {
|
|||
|
|
DEFAULT: "hsl(var(--primary))",
|
|||
|
|
foreground: "hsl(var(--primary-foreground))",
|
|||
|
|
},
|
|||
|
|
secondary: {
|
|||
|
|
DEFAULT: "hsl(var(--secondary))",
|
|||
|
|
foreground: "hsl(var(--secondary-foreground))",
|
|||
|
|
},
|
|||
|
|
destructive: {
|
|||
|
|
DEFAULT: "hsl(var(--destructive))",
|
|||
|
|
foreground: "hsl(var(--destructive-foreground))",
|
|||
|
|
},
|
|||
|
|
muted: {
|
|||
|
|
DEFAULT: "hsl(var(--muted))",
|
|||
|
|
foreground: "hsl(var(--muted-foreground))",
|
|||
|
|
},
|
|||
|
|
accent: {
|
|||
|
|
DEFAULT: "hsl(var(--accent))",
|
|||
|
|
foreground: "hsl(var(--accent-foreground))",
|
|||
|
|
},
|
|||
|
|
popover: {
|
|||
|
|
DEFAULT: "hsl(var(--popover))",
|
|||
|
|
foreground: "hsl(var(--popover-foreground))",
|
|||
|
|
},
|
|||
|
|
card: {
|
|||
|
|
DEFAULT: "hsl(var(--card))",
|
|||
|
|
foreground: "hsl(var(--card-foreground))",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
borderRadius: {
|
|||
|
|
lg: "var(--radius)",
|
|||
|
|
md: "calc(var(--radius) - 2px)",
|
|||
|
|
sm: "calc(var(--radius) - 4px)",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
plugins: [require("tailwindcss-animate")],
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Tailwind CSS v4 스타일 (@theme 사용):**
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* app/globals.css */
|
|||
|
|
@import "tailwindcss";
|
|||
|
|
|
|||
|
|
@theme {
|
|||
|
|
--color-background: hsl(var(--background));
|
|||
|
|
--color-foreground: hsl(var(--foreground));
|
|||
|
|
--color-card: hsl(var(--card));
|
|||
|
|
--color-card-foreground: hsl(var(--card-foreground));
|
|||
|
|
--color-primary: hsl(var(--primary));
|
|||
|
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
|||
|
|
/* ... 나머지 색상 */
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 globals.css 필수 설정
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* app/globals.css */
|
|||
|
|
@tailwind base;
|
|||
|
|
@tailwind components;
|
|||
|
|
@tailwind utilities;
|
|||
|
|
|
|||
|
|
@layer base {
|
|||
|
|
:root {
|
|||
|
|
--background: 0 0% 100%;
|
|||
|
|
--foreground: 222.2 84% 4.9%;
|
|||
|
|
--card: 0 0% 100%;
|
|||
|
|
--card-foreground: 222.2 84% 4.9%;
|
|||
|
|
--popover: 0 0% 100%;
|
|||
|
|
--popover-foreground: 222.2 84% 4.9%;
|
|||
|
|
--primary: 222.2 47.4% 11.2%;
|
|||
|
|
--primary-foreground: 210 40% 98%;
|
|||
|
|
--secondary: 210 40% 96.1%;
|
|||
|
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
|||
|
|
--muted: 210 40% 96.1%;
|
|||
|
|
--muted-foreground: 215.4 16.3% 46.9%;
|
|||
|
|
--accent: 210 40% 96.1%;
|
|||
|
|
--accent-foreground: 222.2 47.4% 11.2%;
|
|||
|
|
--destructive: 0 84.2% 60.2%;
|
|||
|
|
--destructive-foreground: 210 40% 98%;
|
|||
|
|
--border: 214.3 31.8% 91.4%;
|
|||
|
|
--input: 214.3 31.8% 91.4%;
|
|||
|
|
--ring: 222.2 84% 4.9%;
|
|||
|
|
--radius: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dark {
|
|||
|
|
--background: 222.2 84% 4.9%;
|
|||
|
|
--foreground: 210 40% 98%;
|
|||
|
|
--card: 222.2 84% 4.9%;
|
|||
|
|
--card-foreground: 210 40% 98%;
|
|||
|
|
--popover: 222.2 84% 4.9%;
|
|||
|
|
--popover-foreground: 210 40% 98%;
|
|||
|
|
--primary: 210 40% 98%;
|
|||
|
|
--primary-foreground: 222.2 47.4% 11.2%;
|
|||
|
|
--secondary: 217.2 32.6% 17.5%;
|
|||
|
|
--secondary-foreground: 210 40% 98%;
|
|||
|
|
--muted: 217.2 32.6% 17.5%;
|
|||
|
|
--muted-foreground: 215 20.2% 65.1%;
|
|||
|
|
--accent: 217.2 32.6% 17.5%;
|
|||
|
|
--accent-foreground: 210 40% 98%;
|
|||
|
|
--destructive: 0 62.8% 30.6%;
|
|||
|
|
--destructive-foreground: 210 40% 98%;
|
|||
|
|
--border: 217.2 32.6% 17.5%;
|
|||
|
|
--input: 217.2 32.6% 17.5%;
|
|||
|
|
--ring: 212.7 26.8% 83.9%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@layer base {
|
|||
|
|
* {
|
|||
|
|
@apply border-border;
|
|||
|
|
}
|
|||
|
|
body {
|
|||
|
|
@apply bg-background text-foreground;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**중요한 점:**
|
|||
|
|
|
|||
|
|
1. CSS 변수는 **HSL 형식**으로 작성 (예: `222.2 47.4% 11.2%`)
|
|||
|
|
2. `hsl()` 함수는 **사용하지 않음** (Tailwind가 자동으로 추가)
|
|||
|
|
3. 공백으로 구분 (쉼표 없음)
|
|||
|
|
|
|||
|
|
### 1.4 유틸리티 함수 (lib/utils.ts)
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { clsx, type ClassValue } from "clsx";
|
|||
|
|
import { twMerge } from "tailwind-merge";
|
|||
|
|
|
|||
|
|
export function cn(...inputs: ClassValue[]) {
|
|||
|
|
return twMerge(clsx(inputs));
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
이 함수는 **모든 shadcn/ui 컴포넌트에서 필수**입니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. CSS 변수 및 테마 설정
|
|||
|
|
|
|||
|
|
### 2.1 색상 시스템 이해
|
|||
|
|
|
|||
|
|
shadcn/ui는 **시맨틱 색상 시스템**을 사용합니다:
|
|||
|
|
|
|||
|
|
| 색상 변수 | 용도 | 예시 |
|
|||
|
|
| ---------------------- | ------------------------- | ---------------------------- |
|
|||
|
|
| `--background` | 페이지 배경 | 흰색 (라이트), 검은색 (다크) |
|
|||
|
|
| `--foreground` | 기본 텍스트 | 검은색 (라이트), 흰색 (다크) |
|
|||
|
|
| `--primary` | 주요 액션 (버튼, 선택 등) | 진한 네이비/검은색 |
|
|||
|
|
| `--primary-foreground` | Primary 위의 텍스트 | 흰색 |
|
|||
|
|
| `--secondary` | 보조 액션 | 연한 회색 |
|
|||
|
|
| `--accent` | 강조, 호버 효과 | 연한 파란색 |
|
|||
|
|
| `--muted` | 비활성/보조 배경 | 밝은 회색 |
|
|||
|
|
| `--muted-foreground` | 보조 텍스트 (설명 등) | 중간 회색 |
|
|||
|
|
| `--destructive` | 삭제/에러 액션 | 빨간색 |
|
|||
|
|
| `--border` | 테두리 | 밝은 회색 |
|
|||
|
|
| `--input` | 입력 필드 테두리 | 밝은 회색 |
|
|||
|
|
| `--ring` | 포커스 링 | 검은색 |
|
|||
|
|
|
|||
|
|
### 2.2 HSL vs OKLCH 주의사항
|
|||
|
|
|
|||
|
|
**공식 shadcn/ui는 HSL 형식을 사용합니다:**
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* ✅ 올바른 방법 (HSL) */
|
|||
|
|
--primary: 222.2 47.4% 11.2%;
|
|||
|
|
|
|||
|
|
/* ❌ 잘못된 방법 (OKLCH) */
|
|||
|
|
--primary: oklch(0.205 0 0);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
만약 OKLCH를 사용하려면:
|
|||
|
|
|
|||
|
|
1. 모든 컴포넌트에서 `hsl()` → `oklch()` 변경 필요
|
|||
|
|
2. Tailwind 설정 수정 필요
|
|||
|
|
3. 공식 문서와 다른 색상으로 보일 수 있음
|
|||
|
|
|
|||
|
|
**권장: 공식 HSL 형식 그대로 사용**
|
|||
|
|
|
|||
|
|
### 2.3 Border Radius 설정
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
:root {
|
|||
|
|
--radius: 0.5rem; /* 8px, 공식 기본값 */
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
컴포넌트에서 사용:
|
|||
|
|
|
|||
|
|
- `rounded-lg`: `var(--radius)` = 8px
|
|||
|
|
- `rounded-md`: `calc(var(--radius) - 2px)` = 6px
|
|||
|
|
- `rounded-sm`: `calc(var(--radius) - 4px)` = 4px
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 컴포넌트별 구현 가이드
|
|||
|
|
|
|||
|
|
### 3.1 Button 컴포넌트
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|||
|
|
{
|
|||
|
|
variants: {
|
|||
|
|
variant: {
|
|||
|
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|||
|
|
destructive:
|
|||
|
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|||
|
|
outline:
|
|||
|
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|||
|
|
secondary:
|
|||
|
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|||
|
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|||
|
|
link: "text-primary underline-offset-4 hover:underline",
|
|||
|
|
},
|
|||
|
|
size: {
|
|||
|
|
default: "h-10 px-4 py-2",
|
|||
|
|
sm: "h-9 rounded-md px-3",
|
|||
|
|
lg: "h-11 rounded-md px-8",
|
|||
|
|
icon: "h-10 w-10",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
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 };
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**사용법:**
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
|
|||
|
|
<Button variant="default">기본 버튼</Button>
|
|||
|
|
<Button variant="destructive">삭제</Button>
|
|||
|
|
<Button variant="outline">외곽선</Button>
|
|||
|
|
<Button variant="ghost">투명</Button>
|
|||
|
|
<Button size="sm">작은 버튼</Button>
|
|||
|
|
<Button size="icon"><Plus className="h-4 w-4" /></Button>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 Calendar 컴포넌트 (자체 제작 - 완벽 구현)
|
|||
|
|
|
|||
|
|
#### 3.2.1 문제점 및 해결 방안
|
|||
|
|
|
|||
|
|
**react-day-picker v9 문제:**
|
|||
|
|
|
|||
|
|
- 요일 헤더 간격이 이상하게 보임 (MoTuWeThFrSa)
|
|||
|
|
- 클래스명이 변경되어 스타일 적용이 어려움
|
|||
|
|
- `captionLayout="dropdown"`이 제대로 작동하지 않음
|
|||
|
|
|
|||
|
|
**해결책: 자체 Calendar 컴포넌트 제작**
|
|||
|
|
|
|||
|
|
- shadcn/ui 스타일을 완벽하게 재현
|
|||
|
|
- 월/연도 드롭다운 선택 기능 내장
|
|||
|
|
- 크기 조절 가능 (`sm`, `default`, `lg`)
|
|||
|
|
|
|||
|
|
#### 3.2.2 패키지 설치
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# shadcn/ui 기본 컴포넌트만 필요
|
|||
|
|
npm install lucide-react
|
|||
|
|
# react-day-picker는 불필요!
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.2 컴포넌트 코드
|
|||
|
|
|
|||
|
|
**파일 위치:** `components/ui/custom-calendar.tsx`
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import * as React from "react";
|
|||
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import {
|
|||
|
|
Select,
|
|||
|
|
SelectContent,
|
|||
|
|
SelectItem,
|
|||
|
|
SelectTrigger,
|
|||
|
|
SelectValue,
|
|||
|
|
} from "@/components/ui/select";
|
|||
|
|
|
|||
|
|
interface CustomCalendarProps {
|
|||
|
|
selected?: Date;
|
|||
|
|
onSelect?: (date: Date | undefined) => void;
|
|||
|
|
className?: string;
|
|||
|
|
mode?: "single";
|
|||
|
|
size?: "sm" | "default" | "lg";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|||
|
|
const MONTHS = [
|
|||
|
|
"January",
|
|||
|
|
"February",
|
|||
|
|
"March",
|
|||
|
|
"April",
|
|||
|
|
"May",
|
|||
|
|
"June",
|
|||
|
|
"July",
|
|||
|
|
"August",
|
|||
|
|
"September",
|
|||
|
|
"October",
|
|||
|
|
"November",
|
|||
|
|
"December",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export function CustomCalendar({
|
|||
|
|
selected,
|
|||
|
|
onSelect,
|
|||
|
|
className,
|
|||
|
|
mode = "single",
|
|||
|
|
size = "default",
|
|||
|
|
}: CustomCalendarProps) {
|
|||
|
|
// 크기별 클래스 정의
|
|||
|
|
const sizeClasses = {
|
|||
|
|
sm: {
|
|||
|
|
cell: "h-7 w-7 text-xs",
|
|||
|
|
header: "text-xs",
|
|||
|
|
day: "text-[0.7rem]",
|
|||
|
|
nav: "h-6 w-6",
|
|||
|
|
},
|
|||
|
|
default: {
|
|||
|
|
cell: "h-9 w-9 text-sm",
|
|||
|
|
header: "text-[0.8rem]",
|
|||
|
|
day: "text-sm",
|
|||
|
|
nav: "h-7 w-7",
|
|||
|
|
},
|
|||
|
|
lg: {
|
|||
|
|
cell: "h-11 w-11 text-base",
|
|||
|
|
header: "text-sm",
|
|||
|
|
day: "text-base",
|
|||
|
|
nav: "h-8 w-8",
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const currentSize = sizeClasses[size];
|
|||
|
|
const [currentDate, setCurrentDate] = React.useState(selected || new Date());
|
|||
|
|
const [viewYear, setViewYear] = React.useState(currentDate.getFullYear());
|
|||
|
|
const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth());
|
|||
|
|
|
|||
|
|
const getDaysInMonth = (year: number, month: number) => {
|
|||
|
|
return new Date(year, month + 1, 0).getDate();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getFirstDayOfMonth = (year: number, month: number) => {
|
|||
|
|
return new Date(year, month, 1).getDay();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const generateCalendarDays = () => {
|
|||
|
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
|||
|
|
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
|||
|
|
const daysInPrevMonth = getDaysInMonth(viewYear, viewMonth - 1);
|
|||
|
|
|
|||
|
|
const days: Array<{
|
|||
|
|
date: number;
|
|||
|
|
month: "prev" | "current" | "next";
|
|||
|
|
fullDate: Date;
|
|||
|
|
}> = [];
|
|||
|
|
|
|||
|
|
// Previous month days
|
|||
|
|
for (let i = firstDay - 1; i >= 0; i--) {
|
|||
|
|
const date = daysInPrevMonth - i;
|
|||
|
|
days.push({
|
|||
|
|
date,
|
|||
|
|
month: "prev",
|
|||
|
|
fullDate: new Date(viewYear, viewMonth - 1, date),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Current month days
|
|||
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|||
|
|
days.push({
|
|||
|
|
date: i,
|
|||
|
|
month: "current",
|
|||
|
|
fullDate: new Date(viewYear, viewMonth, i),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Next month days
|
|||
|
|
const remainingDays = 42 - days.length;
|
|||
|
|
for (let i = 1; i <= remainingDays; i++) {
|
|||
|
|
days.push({
|
|||
|
|
date: i,
|
|||
|
|
month: "next",
|
|||
|
|
fullDate: new Date(viewYear, viewMonth + 1, i),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return days;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handlePrevMonth = () => {
|
|||
|
|
if (viewMonth === 0) {
|
|||
|
|
setViewMonth(11);
|
|||
|
|
setViewYear(viewYear - 1);
|
|||
|
|
} else {
|
|||
|
|
setViewMonth(viewMonth - 1);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleNextMonth = () => {
|
|||
|
|
if (viewMonth === 11) {
|
|||
|
|
setViewMonth(0);
|
|||
|
|
setViewYear(viewYear + 1);
|
|||
|
|
} else {
|
|||
|
|
setViewMonth(viewMonth + 1);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDateClick = (date: Date) => {
|
|||
|
|
if (onSelect) {
|
|||
|
|
onSelect(date);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isToday = (date: Date) => {
|
|||
|
|
const today = new Date();
|
|||
|
|
return (
|
|||
|
|
date.getDate() === today.getDate() &&
|
|||
|
|
date.getMonth() === today.getMonth() &&
|
|||
|
|
date.getFullYear() === today.getFullYear()
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isSelected = (date: Date) => {
|
|||
|
|
if (!selected) return false;
|
|||
|
|
return (
|
|||
|
|
date.getDate() === selected.getDate() &&
|
|||
|
|
date.getMonth() === selected.getMonth() &&
|
|||
|
|
date.getFullYear() === selected.getFullYear()
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const calendarDays = generateCalendarDays();
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className={cn("p-3", className)}>
|
|||
|
|
{/* Header with Month/Year Dropdowns */}
|
|||
|
|
<div className="flex items-center justify-between gap-2 pb-4">
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="icon"
|
|||
|
|
className={cn(
|
|||
|
|
"bg-transparent p-0 opacity-50 hover:opacity-100",
|
|||
|
|
currentSize.nav
|
|||
|
|
)}
|
|||
|
|
onClick={handlePrevMonth}
|
|||
|
|
>
|
|||
|
|
<ChevronLeft className="h-4 w-4" />
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
{/* Month Select */}
|
|||
|
|
<Select
|
|||
|
|
value={viewMonth.toString()}
|
|||
|
|
onValueChange={(value) => setViewMonth(parseInt(value))}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger
|
|||
|
|
className={cn("w-[110px] font-medium", currentSize.header)}
|
|||
|
|
>
|
|||
|
|
<SelectValue />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{MONTHS.map((month, index) => (
|
|||
|
|
<SelectItem key={index} value={index.toString()}>
|
|||
|
|
{month}
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
|
|||
|
|
{/* Year Select */}
|
|||
|
|
<Select
|
|||
|
|
value={viewYear.toString()}
|
|||
|
|
onValueChange={(value) => setViewYear(parseInt(value))}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger
|
|||
|
|
className={cn("w-[80px] font-medium", currentSize.header)}
|
|||
|
|
>
|
|||
|
|
<SelectValue />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{Array.from({ length: 100 }, (_, i) => {
|
|||
|
|
const year = new Date().getFullYear() - 50 + i;
|
|||
|
|
return (
|
|||
|
|
<SelectItem key={year} value={year.toString()}>
|
|||
|
|
{year}
|
|||
|
|
</SelectItem>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="icon"
|
|||
|
|
className={cn(
|
|||
|
|
"bg-transparent p-0 opacity-50 hover:opacity-100",
|
|||
|
|
currentSize.nav
|
|||
|
|
)}
|
|||
|
|
onClick={handleNextMonth}
|
|||
|
|
>
|
|||
|
|
<ChevronRight className="h-4 w-4" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Days of week */}
|
|||
|
|
<div className="mb-2 grid grid-cols-7 gap-0">
|
|||
|
|
{DAYS.map((day) => (
|
|||
|
|
<div
|
|||
|
|
key={day}
|
|||
|
|
className={cn(
|
|||
|
|
"text-muted-foreground flex items-center justify-center font-normal",
|
|||
|
|
currentSize.cell,
|
|||
|
|
currentSize.day
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{day}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Calendar grid */}
|
|||
|
|
<div className="grid grid-cols-7 gap-0">
|
|||
|
|
{calendarDays.map((day, index) => {
|
|||
|
|
const isOutside = day.month !== "current";
|
|||
|
|
const isTodayDate = isToday(day.fullDate);
|
|||
|
|
const isSelectedDate = isSelected(day.fullDate);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Button
|
|||
|
|
key={index}
|
|||
|
|
variant="ghost"
|
|||
|
|
className={cn(
|
|||
|
|
"p-0 font-normal",
|
|||
|
|
currentSize.cell,
|
|||
|
|
isOutside && "text-muted-foreground opacity-50",
|
|||
|
|
isTodayDate &&
|
|||
|
|
!isSelectedDate &&
|
|||
|
|
"bg-accent text-accent-foreground",
|
|||
|
|
isSelectedDate &&
|
|||
|
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
|||
|
|
)}
|
|||
|
|
onClick={() => handleDateClick(day.fullDate)}
|
|||
|
|
>
|
|||
|
|
{day.date}
|
|||
|
|
</Button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
CustomCalendar.displayName = "CustomCalendar";
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.3 주요 기능
|
|||
|
|
|
|||
|
|
**1. 월/연도 드롭다운 선택**
|
|||
|
|
|
|||
|
|
- 월: January ~ December 전체 선택 가능
|
|||
|
|
- 연도: 현재 연도 ±50년 범위 (총 100년)
|
|||
|
|
- shadcn/ui Select 컴포넌트 사용
|
|||
|
|
|
|||
|
|
**2. 크기 조절 (`size` prop)**
|
|||
|
|
|
|||
|
|
- `sm`: 28px × 28px 셀 (작은 크기)
|
|||
|
|
- `default`: 36px × 36px 셀 (기본 크기)
|
|||
|
|
- `lg`: 44px × 44px 셀 (큰 크기)
|
|||
|
|
|
|||
|
|
**3. 완벽한 요일 간격**
|
|||
|
|
|
|||
|
|
- `grid grid-cols-7`로 정확히 7개 컬럼
|
|||
|
|
- Su Mo Tu We Th Fr Sa 간격 완벽
|
|||
|
|
|
|||
|
|
**4. shadcn/ui 스타일**
|
|||
|
|
|
|||
|
|
- 선택된 날짜: Primary 배경 (검은색)
|
|||
|
|
- 오늘 날짜: Accent 배경 (연한 파란색)
|
|||
|
|
- 외부 날짜: Muted 색상, 50% 투명도
|
|||
|
|
- 호버 효과: Ghost 버튼 스타일
|
|||
|
|
|
|||
|
|
#### 3.2.4 기본 사용법
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { CustomCalendar } from "@/components/ui/custom-calendar";
|
|||
|
|
import { useState } from "react";
|
|||
|
|
|
|||
|
|
export function CalendarDemo() {
|
|||
|
|
const [date, setDate] = useState<Date | undefined>(new Date());
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<CustomCalendar
|
|||
|
|
mode="single"
|
|||
|
|
selected={date}
|
|||
|
|
onSelect={setDate}
|
|||
|
|
className="rounded-md border shadow-sm"
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.5 크기 조절
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{
|
|||
|
|
/* 작은 크기 */
|
|||
|
|
}
|
|||
|
|
<CustomCalendar
|
|||
|
|
size="sm"
|
|||
|
|
selected={date}
|
|||
|
|
onSelect={setDate}
|
|||
|
|
className="rounded-md border"
|
|||
|
|
/>;
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
/* 기본 크기 */
|
|||
|
|
}
|
|||
|
|
<CustomCalendar
|
|||
|
|
size="default"
|
|||
|
|
selected={date}
|
|||
|
|
onSelect={setDate}
|
|||
|
|
className="rounded-md border"
|
|||
|
|
/>;
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
/* 큰 크기 */
|
|||
|
|
}
|
|||
|
|
<CustomCalendar
|
|||
|
|
size="lg"
|
|||
|
|
selected={date}
|
|||
|
|
onSelect={setDate}
|
|||
|
|
className="rounded-md border"
|
|||
|
|
/>;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.6 Date Picker (Popover 결합)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { CustomCalendar } from "@/components/ui/custom-calendar";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import {
|
|||
|
|
Popover,
|
|||
|
|
PopoverContent,
|
|||
|
|
PopoverTrigger,
|
|||
|
|
} from "@/components/ui/popover";
|
|||
|
|
import { useState } from "react";
|
|||
|
|
|
|||
|
|
export function DatePickerDemo() {
|
|||
|
|
const [date, setDate] = useState<Date>();
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Popover>
|
|||
|
|
<PopoverTrigger asChild>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
className="w-[280px] justify-start text-left font-normal"
|
|||
|
|
>
|
|||
|
|
{date ? (
|
|||
|
|
date.toLocaleDateString("ko-KR", {
|
|||
|
|
year: "numeric",
|
|||
|
|
month: "long",
|
|||
|
|
day: "numeric",
|
|||
|
|
})
|
|||
|
|
) : (
|
|||
|
|
<span className="text-muted-foreground">날짜를 선택하세요</span>
|
|||
|
|
)}
|
|||
|
|
<svg
|
|||
|
|
xmlns="http://www.w3.org/2000/svg"
|
|||
|
|
width="24"
|
|||
|
|
height="24"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
strokeLinejoin="round"
|
|||
|
|
className="ml-auto h-4 w-4 opacity-50"
|
|||
|
|
>
|
|||
|
|
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
|
|||
|
|
<line x1="16" x2="16" y1="2" y2="6" />
|
|||
|
|
<line x1="8" x2="8" y1="2" y2="6" />
|
|||
|
|
<line x1="3" x2="21" y1="10" y2="10" />
|
|||
|
|
</svg>
|
|||
|
|
</Button>
|
|||
|
|
</PopoverTrigger>
|
|||
|
|
<PopoverContent className="w-auto p-0">
|
|||
|
|
<CustomCalendar mode="single" selected={date} onSelect={setDate} />
|
|||
|
|
</PopoverContent>
|
|||
|
|
</Popover>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**장점:**
|
|||
|
|
|
|||
|
|
- ✅ 월/연도 드롭다운 선택 가능
|
|||
|
|
- ✅ 완벽한 요일 간격
|
|||
|
|
- ✅ shadcn/ui 스타일 완벽 재현
|
|||
|
|
- ✅ `date-fns` 불필요 (순수 JavaScript)
|
|||
|
|
|
|||
|
|
### 3.3 Dialog (Modal) 컴포넌트
|
|||
|
|
|
|||
|
|
#### 3.3.1 패키지 설치
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm install @radix-ui/react-dialog
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3.2 컴포넌트 코드
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 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,
|
|||
|
|
DialogClose,
|
|||
|
|
DialogTrigger,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogTitle,
|
|||
|
|
DialogDescription,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3.3 표준 사용 패턴
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogDescription,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
DialogTrigger,
|
|||
|
|
} from "@/components/ui/dialog";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
|
|||
|
|
export function DialogDemo() {
|
|||
|
|
return (
|
|||
|
|
<Dialog>
|
|||
|
|
<DialogTrigger asChild>
|
|||
|
|
<Button variant="outline">모달 열기</Button>
|
|||
|
|
</DialogTrigger>
|
|||
|
|
<DialogContent className="sm:max-w-[425px]">
|
|||
|
|
<DialogHeader>
|
|||
|
|
<DialogTitle>프로필 수정</DialogTitle>
|
|||
|
|
<DialogDescription>
|
|||
|
|
프로필 정보를 수정합니다. 완료되면 저장을 클릭하세요.
|
|||
|
|
</DialogDescription>
|
|||
|
|
</DialogHeader>
|
|||
|
|
<div className="grid gap-4 py-4">
|
|||
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|||
|
|
<Label htmlFor="name" className="text-right">
|
|||
|
|
이름
|
|||
|
|
</Label>
|
|||
|
|
<Input id="name" defaultValue="홍길동" className="col-span-3" />
|
|||
|
|
</div>
|
|||
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|||
|
|
<Label htmlFor="username" className="text-right">
|
|||
|
|
사용자명
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="username"
|
|||
|
|
defaultValue="@honggildong"
|
|||
|
|
className="col-span-3"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<DialogFooter>
|
|||
|
|
<Button type="submit">저장</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 문제 해결 (Troubleshooting)
|
|||
|
|
|
|||
|
|
### 4.1 Calendar가 이상하게 보이는 경우
|
|||
|
|
|
|||
|
|
#### 증상 1: 드롭다운이 두 개로 보임
|
|||
|
|
|
|||
|
|
- **원인**: `react-day-picker` v8 스타일과 v9 스타일 혼용
|
|||
|
|
- **해결**:
|
|||
|
|
```tsx
|
|||
|
|
// 올바른 v9 스타일 classNames 사용
|
|||
|
|
classNames={{
|
|||
|
|
caption: "flex justify-center pt-1 relative items-center",
|
|||
|
|
caption_label: "text-sm font-medium",
|
|||
|
|
// ... v9 클래스명 사용
|
|||
|
|
}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 증상 2: 선택된 날짜가 파란 테두리로 보임
|
|||
|
|
|
|||
|
|
- **원인**: `react-day-picker/style.css`를 import함
|
|||
|
|
- **해결**:
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* ❌ 제거해야 함 */
|
|||
|
|
@import "react-day-picker/style.css";
|
|||
|
|
|
|||
|
|
/* ✅ shadcn/ui는 자체 Tailwind 스타일만 사용 */
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 증상 3: 색상이 공식 문서와 다름
|
|||
|
|
|
|||
|
|
- **원인**: CSS 변수가 잘못 설정됨
|
|||
|
|
- **해결**:
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* ✅ 올바른 HSL 형식 */
|
|||
|
|
--primary: 222.2 47.4% 11.2%;
|
|||
|
|
|
|||
|
|
/* ❌ 잘못된 OKLCH 형식 */
|
|||
|
|
--primary: oklch(0.205 0 0);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 증상 4: 드롭다운 화살표가 없음
|
|||
|
|
|
|||
|
|
- **원인**: `captionLayout="dropdown"`을 사용했지만 커스텀 CSS가 없음
|
|||
|
|
- **해결**: `react-day-picker` v9는 자동으로 네이티브 `<select>` 드롭다운을 생성하므로 추가 CSS 불필요
|
|||
|
|
|
|||
|
|
### 4.2 색상이 적용되지 않는 경우
|
|||
|
|
|
|||
|
|
#### 체크리스트:
|
|||
|
|
|
|||
|
|
1. ✅ `globals.css`에 CSS 변수 정의됨
|
|||
|
|
2. ✅ CSS 변수가 **HSL 형식**으로 작성됨 (쉼표 없이 공백으로 구분)
|
|||
|
|
3. ✅ Tailwind 설정에서 색상 확장 정의됨
|
|||
|
|
4. ✅ `hsl(var(--color))` 형식으로 사용
|
|||
|
|
5. ✅ 브라우저 캐시 지움 (Hard Refresh)
|
|||
|
|
|
|||
|
|
### 4.3 Tailwind 클래스가 작동하지 않는 경우
|
|||
|
|
|
|||
|
|
#### 원인:
|
|||
|
|
|
|||
|
|
- Tailwind가 해당 파일을 스캔하지 못함
|
|||
|
|
|
|||
|
|
#### 해결:
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
// tailwind.config.js
|
|||
|
|
module.exports = {
|
|||
|
|
content: [
|
|||
|
|
"./pages/**/*.{ts,tsx}",
|
|||
|
|
"./components/**/*.{ts,tsx}",
|
|||
|
|
"./app/**/*.{ts,tsx}",
|
|||
|
|
"./src/**/*.{ts,tsx}",
|
|||
|
|
],
|
|||
|
|
// ...
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 컴포넌트가 렌더링되지 않는 경우
|
|||
|
|
|
|||
|
|
#### 체크리스트:
|
|||
|
|
|
|||
|
|
1. ✅ 필요한 Radix UI 패키지 설치됨
|
|||
|
|
2. ✅ `"use client"` 디렉티브 추가됨 (Next.js App Router)
|
|||
|
|
3. ✅ `cn()` 유틸리티 함수 존재
|
|||
|
|
4. ✅ Import 경로 올바름 (`@/components/ui/...`)
|
|||
|
|
|
|||
|
|
### 4.5 스타일 충돌 문제
|
|||
|
|
|
|||
|
|
#### 증상: 커스텀 CSS가 shadcn/ui 스타일을 덮어씀
|
|||
|
|
|
|||
|
|
#### 해결:
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* ❌ 글로벌 스타일로 덮어쓰지 말 것 */
|
|||
|
|
button {
|
|||
|
|
background-color: blue !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ✅ 특정 클래스에만 적용 */
|
|||
|
|
.my-custom-button {
|
|||
|
|
background-color: blue;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 베스트 프랙티스
|
|||
|
|
|
|||
|
|
### 5.1 컴포넌트 커스터마이징
|
|||
|
|
|
|||
|
|
**공식 shadcn/ui 컴포넌트는 수정하지 말고, 래핑하여 확장:**
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// ❌ 나쁜 예: 직접 수정
|
|||
|
|
// components/ui/button.tsx 파일을 직접 수정
|
|||
|
|
|
|||
|
|
// ✅ 좋은 예: 래핑하여 확장
|
|||
|
|
// components/custom-button.tsx
|
|||
|
|
import { Button, ButtonProps } from "@/components/ui/button";
|
|||
|
|
|
|||
|
|
interface CustomButtonProps extends ButtonProps {
|
|||
|
|
loading?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function CustomButton({
|
|||
|
|
loading,
|
|||
|
|
children,
|
|||
|
|
...props
|
|||
|
|
}: CustomButtonProps) {
|
|||
|
|
return (
|
|||
|
|
<Button {...props} disabled={loading || props.disabled}>
|
|||
|
|
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|||
|
|
{children}
|
|||
|
|
</Button>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 CSS 변수 커스터마이징
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* ✅ 프로젝트별 색상 테마 정의 */
|
|||
|
|
:root {
|
|||
|
|
/* Primary를 브랜드 색상으로 */
|
|||
|
|
--primary: 200 100% 50%; /* 파란색 */
|
|||
|
|
--primary-foreground: 0 0% 100%; /* 흰색 */
|
|||
|
|
|
|||
|
|
/* 나머지는 공식 기본값 유지 */
|
|||
|
|
--secondary: 210 40% 96.1%;
|
|||
|
|
/* ... */
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 반응형 디자인
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// shadcn/ui는 모바일 우선 (Mobile-First)
|
|||
|
|
<div className="flex-col md:flex-row">
|
|||
|
|
{/* 모바일: 세로 배치, 데스크톱: 가로 배치 */}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Dialog>
|
|||
|
|
<DialogContent className="sm:max-w-[425px]">
|
|||
|
|
{/* 모바일: 전체 너비, 데스크톱: 425px */}
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.4 다크 모드 지원
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// app/layout.tsx
|
|||
|
|
<html lang="ko" className={isDark ? "dark" : ""}>
|
|||
|
|
<body>{children}</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
모든 shadcn/ui 컴포넌트는 `.dark` 클래스로 자동 다크모드 지원
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. 자주 묻는 질문 (FAQ)
|
|||
|
|
|
|||
|
|
### Q1: shadcn/ui는 컴포넌트 라이브러리인가요?
|
|||
|
|
|
|||
|
|
**A**: 아닙니다. shadcn/ui는 **복사-붙여넣기 가능한 컴포넌트 모음**입니다. npm 패키지로 설치하지 않고, 코드를 직접 프로젝트에 복사하여 커스터마이징합니다.
|
|||
|
|
|
|||
|
|
### Q2: 모든 컴포넌트를 한 번에 설치할 수 있나요?
|
|||
|
|
|
|||
|
|
**A**: 공식 CLI를 사용하면 가능합니다:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npx shadcn-ui@latest init
|
|||
|
|
npx shadcn-ui@latest add button
|
|||
|
|
npx shadcn-ui@latest add calendar
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Q3: CSS 변수를 꼭 사용해야 하나요?
|
|||
|
|
|
|||
|
|
**A**: 네. shadcn/ui의 핵심은 CSS 변수 기반 테마 시스템입니다. 이를 통해 일관된 디자인과 다크모드를 쉽게 구현할 수 있습니다.
|
|||
|
|
|
|||
|
|
### Q4: Tailwind CSS 없이 사용할 수 있나요?
|
|||
|
|
|
|||
|
|
**A**: 불가능합니다. shadcn/ui는 Tailwind CSS와 완전히 통합되어 있습니다.
|
|||
|
|
|
|||
|
|
### Q5: 공식 문서와 색상이 다른 이유는?
|
|||
|
|
|
|||
|
|
**A**: CSS 변수 값이 다르기 때문입니다. 공식 기본값으로 변경하거나, 프로젝트에 맞게 커스터마이징할 수 있습니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. 결론
|
|||
|
|
|
|||
|
|
shadcn/ui를 성공적으로 구현하기 위한 핵심:
|
|||
|
|
|
|||
|
|
1. ✅ **공식 코드를 그대로 사용** (임의로 수정하지 말 것)
|
|||
|
|
2. ✅ **HSL 형식의 CSS 변수** 사용
|
|||
|
|
3. ✅ **외부 CSS import 하지 말 것** (Tailwind만 사용)
|
|||
|
|
4. ✅ **Radix UI 패키지 버전 확인**
|
|||
|
|
5. ✅ **브라우저 캐시 주의** (Hard Refresh 습관화)
|
|||
|
|
|
|||
|
|
이 가이드를 따르면 공식 문서와 **완전히 동일한** UI를 구현할 수 있습니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 참고 자료
|
|||
|
|
|
|||
|
|
- 공식 문서: https://ui.shadcn.com
|
|||
|
|
- GitHub: https://github.com/shadcn-ui/ui
|
|||
|
|
- Radix UI: https://www.radix-ui.com
|
|||
|
|
- Tailwind CSS: https://tailwindcss.com
|