React

 

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를 활용하여 드래그 닫기 지원

 

 

 

 

 

about author

PHRASE

Level 60  라이트

인간은 세 가지 벗을 가지고 있다. 아이, 부(富), 선행. -탈무드

댓글 ( 0)

댓글 남기기

작성