우리가 흔히 사용하는 공통 컴포넌트(Input, Dropdown 등)를 사용할 수 있도록 제공하는 shadcn에 대해서 알게되어서 소개해보려고 한다.
github를 봤을 때, 예전에 구현햇었던 합성 컴포넌트 방식과 약간 유사하다고 생각해서 무엇인지에 대해서 알아보았다.
shadcn이 무엇인가?
https://ui.shadcn.com
컴포넌트 라이브러리가 아니고, 복사&붙여넣을 수 있는 재사용 가능한 컴포넌트의 모음
이라고 스스로 정의하고 있다.
이때 컴포넌트 라이브러리가 아니라는 것은 npm으로 다운받아 사용하는 것이 아니라는 의미이다. 코드를 복사해서 붙여넣고, 필요에 의해 수정하는 방식으로 사용하면 된다. 의존성을 추가하지 않아도 사용할 수 없다.
Radix UI의 저수준 컴포넌트를 활용하여 고수준 컴포넌트로 제공하고 있다.
Radix UI는 무엇일까?
https://www.radix-ui.com
최적화 된 웹 접근성을 구성으로 구현된 컴포넌트 오픈소스로, 스타일링이 최소화된 저수준의 컴포넌트들로 이루어져 있습니다.
tailwindCSS를 기반으로 구현되어 있다.
스크린 리더와 키보드 내비게이션을 기본적으로 지원하고 있다.
코드로 보자
shadcn의 Dropdown 컴포넌트를 가지고 와 보았다. 사용할 때는 합성 컴포넌트를 사용하듯이 조합해서 사용하면 되고, 사용 예시를 shadcn의 웹 사이트에서 확인할 수 있다.
처음에 shadcn의 dropdown을 활용해서 내가 사용하던 dropdown을 구현해보려고 했는데, Radix UI에 대한 사전 지식이 없어서 조금 헤맸었던 것 같다.
하지만, Radix UI에서는 open 여부를 확인할 수 있는 onOpenChange, 선택된 value를 확인하고 변경하는 value와 onValueChange 등 기본적인 Dropdown 컴포넌트의 hook을 반영할 수 있는 여러 핸들러 함수를 제공하고 있다.
Github에서 확인한 Dropdown컴포넌트
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
cn을 통해서 스타일 코드를 병합하고 있고, forwardRef를 이용해서 ref도 사용할 수 있도록 하고 있다.
shadcn의 DropdownMenuRadioGroupDemo를 활용한 Dropdown 만들기
"use client";
import { useState } from "react";
import Image from "next/image";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import smallArrowIcon from "@/public/icons/smallArrow.svg";
import smallTopArrowIcon from "@/public/icons/smallTopArrow.svg";
// 다루는 value의 type을 제네릭으로 받아서 여러 dropdown에 재사용 가능하도록 했다.
type DropDownProps<T> = {
selectValue: T;
selectOption: T[];
handleChangeValue: (value: T) => void;
};
export function DropdownMenuRadioGroupDemo<T = unknown>({
selectValue,
selectOption,
handleChangeValue,
}: DropDownProps<T>) {
// radix에서는 data-* 클래스를 이용해서 open state를 관리하고 있고,
//변경되는 값을 onOpenChange를 통해 가져올 수 있어, 이를 통해 화살표 image를 바꿔주고 있다.
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownMenu onOpenChange={open => setIsOpen(open)}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={
"flex w-full justify-between rounded-md border border-solid border-gray-200 p-2.5 text-left text-sm"
}
id="dropdown-trigger">
// value의 type이 기본적으로 string으로 되어 있어서 타입 변환을 해주고 있다.
{selectValue ? String(selectValue) : "값을 선택해주세요."}
{isOpen ? (
<Image src={smallTopArrowIcon} alt="드롭다운 열기" width={20} height={20} priority />
) : (
<Image src={smallArrowIcon} alt="드롭다운 닫기" width={20} height={20} priority />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-md"
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={String(selectValue)} onValueChange={value => handleChangeValue(value as T)}>
{selectOption.map(option => (
<DropdownMenuRadioItem key={String(option)} value={String(option)}>
{String(option)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
구현에 집중하다보니 타입이 안맞는 경우 as나 String(value) 등을 통해 타입을 강제로 맞춰주었지만, interface로 되어 있는 타입을 재정의 해서 제네릭을 받아갈 수 있도록 바꿔주는 것이 좋을 것 같다.
확실히 웹 접근성을 고려한 컴포넌트를 활용했더니, 키보드로도 선택이 자연스럽게 된다.
화면이 아래로 많이 스크롤 되어 있으면 자동으로 위쪽으로 드롭다운이 열리는 디테일이 있었다. 개발자의 입장에서 이런 예외상황까지 고려한 UI를 가져다가 커스텀할 수 있으니 정말 좋다는 생각이 들었다.
shadcn의 장단점에 대해서 생각해보자
장점
내가 드롭다운 하나만 가지고 사용을 해봤는데, 확실히 키보드 내비게이션을 제공한다는 점이 너무 좋았던 것 같다. 사용자의 입장에서 너무 편하고, 개발자의 입장에서 그런 것까지 고려하지 않아도 되니 좋았다.
기본적으로 동작하는 로직과 아주 간단한 UI가 적용된 컴포넌트를 가지고 개발하기 때문에 일관성 유지하기에도 좋을 것 같다.
단점
기본적으로 Radix UI에 대한 이해도가 있어야하기 때문에, shadcn과 Radix UI에 대한 학습이 필요하다. 그래서 간단한 기능만 필요하다면 불필요한 시간을 들여야할 수도 있다.
나는 TailwindCSS를 원래 자주 사용하던 터라 괜찮았지만, TailwindCSS의 유틸리티 클래스를 적극적으로 활용하고 있는 라이브러리이기 때문에 TailwindCSS가 아니라면 사용이 힘들다.
총평
기본적으로 많이 사용되지만, 은근히 고려해야하는 것이 많은. 그런 컴포넌트들에 적용하면 좋을 것 같다고 생각했다.
기본적으로 제공되는 UI가 있기 때문에 이를 활용해서 디자인 시스템을 구축해보아도 좋겠다고 생각했다. 일관성도 챙기고, 우리 프로젝트에 맞는 스타일을 입히기만 하면 되기 때문이다. 그리고 동작하는 로직에서도 내가 사용할 hook을 적절하게 결합하면 되어서, 조금 학습만 한다면 충분히 사용할 가치가 있다고 생각한다.
지금까지는 직접 개발하는 것에 초점을 맞춰서 프로젝트를 진행해왔는데, 최근에는 이런 라이브러리들을 더 잘 활용하기 위한 방법에 대해서 생각해보게 되는 것 같다.
그럼 오늘은 이만 !
안뇽 ~!
'🗂️ 개발 이모저모' 카테고리의 다른 글
twMerge + cva + cx로 tailwind의 style-variant 작성하기 (1) | 2024.12.10 |
---|---|
node_modules, 패키지 크기가 작아지면 좋은 점이 뭘까? (2) | 2024.12.02 |
스토리 북에 대해서 알아보자 ! (3) | 2024.11.04 |
카카오톡 공유하기를 내 맘대로 바꿔보자 (with.기본 템플릿 커스텀) (6) | 2024.10.03 |
카카오톡 공유하기를 원하는 대로 만들어보자 ! (with. 메시지 템플릿) (5) | 2024.09.29 |