https://ui.shadcn.com/docs/components/drawer
설치
pnpm dlx shadcn@latest add drawer
Drawer 컴포넌트를 다음과 같이 변경합니다.
1)모바일 메뉴 좌우 50% 가리기 소스 : drawer.tsx
src/app/components/ui/drawer-50.tsx
"use client"; import * as React from "react"; import { Drawer as DrawerPrimitive } from "vaul"; import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; const DrawerContext = React.createContext<{ direction?: "right" | "top" | "bottom" | "left"; }>({ direction: "right", }); const Drawer = ({ shouldScaleBackground = true, direction = "right", ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( <DrawerContext.Provider value={{ direction }}> <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} direction={direction} {...props} /> </DrawerContext.Provider> ); Drawer.displayName = "Drawer"; const DrawerTrigger = DrawerPrimitive.Trigger; const DrawerPortal = DrawerPrimitive.Portal; const DrawerClose = DrawerPrimitive.Close; const DrawerOverlay = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} /> )); DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; const drawerContentVariants = cva( "fixed z-50 flex h-auto flex-col border bg-background", { variants: { direction: { right: "ml-24 right-0 rounded-l-[10px] inset-y-0", top: "mb-24 top-0 rounded-b-[10px] inset-x-0", bottom: "mt-24 rounded-t-[10px] bottom-0 inset-x-0", left: "mr-24 left-0 rounded-r-[10px] inset-y-0", }, }, defaultVariants: { direction: "right", }, } ); const DrawerContent = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> >(({ className, children, ...props }, ref) => { const { direction } = React.useContext(DrawerContext); return ( <DrawerPortal> <DrawerOverlay /> <DrawerPrimitive.Content ref={ref} className={cn(drawerContentVariants({ direction, className }))} {...props} > {/* <div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' /> */} {children} </DrawerPrimitive.Content> </DrawerPortal> ); }); DrawerContent.displayName = "DrawerContent"; const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} /> ); DrawerHeader.displayName = "DrawerHeader"; const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> ); DrawerFooter.displayName = "DrawerFooter"; const DrawerTitle = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Title ref={ref} className={cn( "text-lg font-semibold leading-none tracking-tight", className )} {...props} /> )); DrawerTitle.displayName = DrawerPrimitive.Title.displayName; const DrawerDescription = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> )); DrawerDescription.displayName = DrawerPrimitive.Description.displayName; export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };
2)모바일화면에서 좌추 100% 풀 가리기 소스 : drawer.tsx
src/app/components/ui/drawer-100.tsx
"use client"; import * as React from "react"; import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "@/lib/utils"; export const DrawerContext = React.createContext<{ direction?: "top" | "bottom" | "left" | "right" ; onClose?: () => void; }>({}); const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( <DrawerContext.Provider value={{ direction: props.direction, onClose: props.onClose }} > <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} /> </DrawerContext.Provider> ); Drawer.displayName = "Drawer"; const DrawerTrigger = DrawerPrimitive.Trigger; const DrawerPortal = DrawerPrimitive.Portal; const DrawerClose = DrawerPrimitive.Close; const DrawerOverlay = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} /> )); DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; const DrawerContent = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> >(({ className, children, ...props }, ref) => { const { direction } = React.useContext(DrawerContext); return ( <DrawerPortal> <DrawerOverlay /> <DrawerPrimitive.Content ref={ref} className={cn( "fixed z-50 flex h-auto flex-col rounded-t-[10px] border bg-background", (!direction || direction === "bottom") && "inset-x-0 bottom-0 mt-24 ", direction === "right" && "right-0 w-screen max-w-md top-0 h-full", direction === "top" && "inset-x-0 top-0 mb-24 rounded-b-[10px]", direction === "left" && "left-0 w-screen max-w-md top-0 h-full", className )} {...props} > {(!direction || direction === "bottom") && ( <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> )} {direction === "top" && ( <div className="mx-auto mb-4 h-2 w-[100px] rounded-full bg-muted" /> )} {children} </DrawerPrimitive.Content> </DrawerPortal> ); }); DrawerContent.displayName = "DrawerContent"; const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} /> ); DrawerHeader.displayName = "DrawerHeader"; const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> ); DrawerFooter.displayName = "DrawerFooter"; const DrawerTitle = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Title ref={ref} className={cn( "text-lg font-semibold leading-none tracking-tight", className )} {...props} /> )); DrawerTitle.displayName = DrawerPrimitive.Title.displayName; const DrawerDescription = React.forwardRef< React.ElementRef<typeof DrawerPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> >(({ className, ...props }, ref) => ( <DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> )); DrawerDescription.displayName = DrawerPrimitive.Description.displayName; export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };
기본적인 사용 예제
"use client"; import React from "react"; import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerClose } from "@/components/Drawer"; import { Button } from "@/components/ui/button"; export default function Example() { return ( <Drawer> <DrawerTrigger asChild> <Button variant="outline">Open Drawer</Button> </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Drawer 제목</DrawerTitle> <DrawerDescription>이것은 Drawer 설명입니다.</DrawerDescription> </DrawerHeader> <div className="p-4"> <p>여기에 원하는 내용을 넣을 수 있습니다.</p> </div> <DrawerFooter> <DrawerClose asChild> <Button variant="secondary">닫기</Button> </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> ); }
설명
DrawerTrigger
- Drawer를 여는 버튼 역할을 합니다.
- asChild를 사용하여 버튼을 감싸면, Button 자체가 DrawerTrigger로 작동합니다.
DrawerContent
- Drawer 내부의 컨텐츠를 표시하는 영역입니다.
- DrawerHeader, DrawerFooter 등을 포함할 수 있습니다.
DrawerHeader, DrawerFooter
- 각각 DrawerTitle, DrawerDescription, 버튼 등을 배치할 수 있습니다.
DrawerClose
- Drawer를 닫는 기능을 합니다. asChild를 사용하면 버튼 등 원하는 엘리먼트에 적용할 수 있습니다.
추가 옵션
direction="bottom", direction="left" 등을 사용하여 Drawer의 방향을 조정할 수 있습니다.
Drawer 방향 설정 예제
<Drawer direction="left"> <DrawerTrigger asChild> <Button variant="outline">왼쪽에서 열기</Button> </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>왼쪽 Drawer</DrawerTitle> </DrawerHeader> </DrawerContent> </Drawer>
정리
- Drawer를 감싸고 DrawerTrigger로 열기 버튼을 만든다.
- DrawerContent 내부에 DrawerHeader, DrawerFooter, 원하는 내용을 배치한다.
- DrawerClose를 사용하여 닫기 기능을 추가한다.
- direction 속성을 변경하면 위치를 조정할 수 있다.
※반응형 모바일 위하여 미디어쿼리 추가
https://ui.shadcn.com/docs/components/drawer
Dialog및 구성 요소를 결합하여 Drawer반응형 대화 상자를 만들 수 있습니다.
이렇게 하면 Dialog데스크톱에서 구성 요소를 렌더링하고 Drawer모바일에서 구성 요소를 렌더링합니다.
https://github.com/shadcn-ui/ui/blob/main/apps/www/hooks/use-media-query.tsx
use-media-query.tsx hook 디렉토리 에 추가
import * as React from "react" export function useMediaQuery(query: string) { const [value, setValue] = React.useState(false) React.useEffect(() => { function onChange(event: MediaQueryListEvent) { setValue(event.matches) } const result = matchMedia(query) result.addEventListener("change", onChange) setValue(result.matches) return () => result.removeEventListener("change", onChange) }, [query]) return value }
1)DashboardLayout
"use client"; import React from 'react' import MainMenu from './components/main-menu' import MenuTitle from './components/menu-title' import MobileMenu from './components/mobile-menu' import { useMediaQuery } from '@/hooks/use-media-query' interface DashboardLayoutProps { children: React.ReactNode } const DashboardLayout:React.FC<DashboardLayoutProps> = ({children}) => { const isDesktop = useMediaQuery("(min-width: 768px)"); return ( <div className='grid md:grid-cols-[250px_1fr] px-3 md:px-0 h-screen'> <MainMenu className="hidden md:flex" /> {!isDesktop && ( <div className='p-4 flex justify-between md:hidden sticky top-0 left-0 bg-background border-b border-border'> <MenuTitle /> <MobileMenu /> </div> )} <div className='overflow-auto py-2 px-6'> <h1 className='pb-4 text-2xl font-bold'>환영합니다. 홍길동님!</h1> {children} </div> </div> ) } export default DashboardLayout
2)mobile-menu.tsx
import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerClose, DrawerContent,DrawerHeader, DrawerDescription, DrawerFooter, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer-100"; import { MenuIcon, X } from "lucide-react"; import MenuList from "./menu-list"; const MobileMenu:React.FC = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); return ( <Drawer direction="right" open={mobileMenuOpen} onClose={() => setMobileMenuOpen(false)} onOpenChange={(open)=>setMobileMenuOpen(open)}> <DrawerTrigger asChild className="cursor-pointer"> <MenuIcon/> </DrawerTrigger> <DrawerContent> <DrawerHeader className="hidden"> <DrawerTitle> </DrawerTitle> <DrawerDescription></DrawerDescription> </DrawerHeader> <div className="p-0 h-full bg-muted"> <DrawerClose asChild className="absolute top-4 right-4"> <Button variant="ghost" className="w-10 h-10 p-2 rounded-full bg-gray-200 hover:bg-gray-300 transition-all"> <X className="w-5 h-5" /> </Button> </DrawerClose> <MenuList className="md:flex" /> </div> <DrawerFooter className="hidden"> <DrawerClose asChild> <Button variant="secondary">닫기</Button> </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> ) } export default MobileMenu;
3)/src/app/components/common/menu-item.tsx
"use client"; import { DrawerContext } from '@/components/ui/drawer-100'; import { cn } from '@/lib/utils'; import Link from 'next/link'; import { usePathname } from 'next/navigation' import React, { useContext } from 'react' interface MenuItemProps { children: React.ReactNode, href: string } const MenuItem:React.FC<MenuItemProps> = ({children, href}) => { const {onClose} = useContext(DrawerContext); const pathname=usePathname(); const isActive = pathname === href; return ( <li> <Link href={href} onClick={onClose} className={cn("block p-2 hover:bg-white dark:hover:bg-zinc-700 rounded-md text-muted-foreground text-sm font-medium", isActive && "bg-primary hover:bg-primary dark:hover:bg-primary hover:text-primary-foreground text-primary-foreground ")} > {children} </Link> </li> ) } export default MenuItem;
4)/src/app/components/common/menu-title.tsx
import React from 'react' const MenuTitle:React.FC = () => { return ( <h4 className='flex items-center text-2xl'> <span className="text-4xl font-bold text-pink-500 mr-2">M</span> Macaronics </h4> ) } export default MenuTitle;
5)/src/app/components/common/menu-list.tsx
import React from "react"; import {cn} from "@/lib/utils"; import MenuItem from "./menu-item"; import MenuTitle from "./menu-title"; interface MainMenuProps { className?: string; } const MenuList: React.FC<MainMenuProps> = ({className}) => { return ( <nav className={cn(`bg-muted overflow-auto p-4 flex flex-col`,className)} > <header className="border-b dark:border-b-black border-b-zinc-300 pb-4"> <MenuTitle /> </header> <ul className="py-4 grow flex flex-col gap-1"> <MenuItem href="/dashboard" >대시보드</MenuItem> <MenuItem href="/dashboard/userInfo" >유저정보</MenuItem> <MenuItem href="/dashboard/userInfo/update">시용자업데이트</MenuItem> <MenuItem href="/dashboard/teams">팀</MenuItem> <MenuItem href="/dashboard/employee">직원</MenuItem> <MenuItem href="/dashboard/account">시용자정보</MenuItem> <MenuItem href="/dashboard/settings">설정</MenuItem> <MenuItem href="/dashboard/nested-menu">3단메뉴</MenuItem> <MenuItem href="/dashboard/payments">결제</MenuItem> <MenuItem href="/products">상품</MenuItem> <MenuItem href="/dashboard/daum-address">다음주소입력</MenuItem> </ul> </nav> ); }; export default MenuList;
※모바일 메뉴 닫기(Closing) 기능 구현 설명
모바일 메뉴에서 링크를 클릭할 때 자동으로 닫히도록 구현하는 핵심적인 로직을 설명하겠습니다.
1. DrawerContext를 활용한 상태 관리
- React.createContext를 사용하여 DrawerContext를 생성했습니다.
- onClose라는 함수를 컨텍스트에서 제공하여 메뉴 항목에서 이를 호출하면 Drawer가 닫히도록 설계했습니다.
export const DrawerContext = React.createContext<{ direction?: "top" | "bottom" | "left" | "right"; onClose?: () => void; }>({});
onClose가 정의되지 않을 수도 있기 때문에 ?(optional)로 선언
2. Drawer 컴포넌트에서 onClose 값 전달
- DrawerContext.Provider를 이용해 onClose 값을 컨텍스트에 전달
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( <DrawerContext.Provider value={{ direction: props.direction, onClose: props.onClose }} > <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} /> </DrawerContext.Provider> );
이렇게 하면 Drawer 내부의 컴포넌트들이 onClose를 사용할 수 있음
3. MenuItem에서 onClose 실행
- MenuItem에서 useContext(DrawerContext)를 통해 onClose 값을 가져와 링크 클릭 시 실행
const MenuItem: React.FC<MenuItemProps> = ({ children, href }) => { const { onClose } = useContext(DrawerContext); const pathname = usePathname(); const isActive = pathname === href; return ( <li> <Link href={href} onClick={onClose} // 클릭하면 onClose 실행하여 Drawer 닫기 className={cn( "block p-2 hover:bg-white dark:hover:bg-zinc-700 rounded-md text-muted-foreground text-sm font-medium", isActive && "bg-primary hover:bg-primary dark:hover:bg-primary hover:text-primary-foreground text-primary-foreground" )} > {children} </Link> </li> ); };
링크 클릭 시 onClose가 실행되면서 Drawer가 닫힘
4. onClose의 동작 원리
Drawer에서 onClose를 false로 설정하는 setState와 연결해 닫기 동작을 수행
const [isOpen, setIsOpen] = React.useState(false); const handleClose = () => { setIsOpen(false); // Drawer를 닫음 }; <Drawer open={isOpen} onClose={handleClose} // 이 값이 MenuItem까지 전달됨 onOpenChange={setIsOpen} />
결국 onClose를 호출하면 setIsOpen(false)가 실행되어 Drawer가 닫힘
5. onClose가 undefined인 경우 대비
- 모든 MenuItem이 Drawer 내부에서 렌더링되는 것은 아니므로 onClose가 없는 경우도 있음
- 이를 고려하여 onClose가 존재하는 경우에만 실행
<Link href={href} onClick={onClose ? onClose : undefined}>
onClose가 없으면 아무 일도 일어나지 않음
6. 드래그 닫기 지원 (onOpenChange 활용)
- Drawer를 드래그하여 닫을 경우 onOpenChange 이벤트가 실행됨
- onOpenChange에서 setIsOpen(false)를 실행하여 닫기
<Drawer open={isOpen} onOpenChange={(open) => setIsOpen(open)} onClose={() => setIsOpen(false)} />
드래그해서 닫으면 onOpenChange(false)가 실행되면서 닫힘
정리
✅ DrawerContext를 사용하여 onClose를 제공
✅ MenuItem에서 onClose 실행하여 Drawer 닫기
✅ onClose가 undefined일 경우 대비
✅ onOpenChange를 활용하여 드래그 닫기 지원
댓글 ( 0)
댓글 남기기