-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 바텀시트 모달 추가 및 UI 수정 #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "55-feat-\uBC14\uD140\uC2DC\uD2B8-\uBAA8\uB2EC-\uCD94\uAC00-\uBC0F-ui-\uC218\uC815"
Changes from all commits
df7b766
e3be569
0660002
3b7e90e
c05d24d
2a0ef33
90e7950
c049ece
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { useRef, type HTMLAttributes, type ReactNode } from 'react'; | ||
| import { twMerge } from 'tailwind-merge'; | ||
| import useClickTouchOutside from '@/utils/hooks/useClickTouchOutside'; | ||
| import useScrollLock from '@/utils/hooks/useScrollLock'; | ||
| import Portal from './Portal'; | ||
|
|
||
| interface BottomModalProps extends HTMLAttributes<HTMLDivElement> { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| function BottomModal({ isOpen, onClose, children, className }: BottomModalProps) { | ||
| const modalRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| useClickTouchOutside(modalRef, onClose); | ||
| useScrollLock(isOpen); | ||
|
|
||
| if (!isOpen) return null; | ||
|
|
||
| return ( | ||
| <Portal> | ||
| <div className="fixed inset-0 z-100 bg-black/60"> | ||
| <div | ||
| ref={modalRef} | ||
| role="dialog" | ||
| aria-modal="true" | ||
| className={twMerge('fixed inset-x-0 bottom-0 rounded-t-3xl bg-white', className)} | ||
| > | ||
| {children} | ||
| </div> | ||
| </div> | ||
| </Portal> | ||
| ); | ||
| } | ||
|
|
||
| export default BottomModal; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,79 @@ | ||||||||||||||||||||||||||||||||||||||||
| import type { ReactNode } from 'react'; | ||||||||||||||||||||||||||||||||||||||||
| import clsx from 'clsx'; | ||||||||||||||||||||||||||||||||||||||||
| import { useBottomSheet, type SheetPosition } from '@/utils/hooks/useBottomSheet'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| interface BottomSheetProps { | ||||||||||||||||||||||||||||||||||||||||
| /** children으로 ReactNode 또는 render prop 함수 전달 가능 */ | ||||||||||||||||||||||||||||||||||||||||
| children: ReactNode | ((position: SheetPosition) => ReactNode); | ||||||||||||||||||||||||||||||||||||||||
| /** 드래그로 크기 조절 가능 여부 (기본값: false) */ | ||||||||||||||||||||||||||||||||||||||||
| resizable?: boolean; | ||||||||||||||||||||||||||||||||||||||||
| /** 초기 위치 (기본값: 'half') */ | ||||||||||||||||||||||||||||||||||||||||
| defaultPosition?: SheetPosition; | ||||||||||||||||||||||||||||||||||||||||
| /** 하단 오프셋 (px) */ | ||||||||||||||||||||||||||||||||||||||||
| bottomOffset?: number; | ||||||||||||||||||||||||||||||||||||||||
| /** half 상태일 때 상단 오프셋 (px) */ | ||||||||||||||||||||||||||||||||||||||||
| halfTopOffset?: number; | ||||||||||||||||||||||||||||||||||||||||
| /** full 상태일 때 상단 오프셋 (px) */ | ||||||||||||||||||||||||||||||||||||||||
| fullTopOffset?: number; | ||||||||||||||||||||||||||||||||||||||||
| /** 추가 className */ | ||||||||||||||||||||||||||||||||||||||||
| className?: string; | ||||||||||||||||||||||||||||||||||||||||
| /** 현재 position을 외부로 전달하는 콜백 */ | ||||||||||||||||||||||||||||||||||||||||
| onPositionChange?: (position: SheetPosition) => void; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export default function BottomSheet({ | ||||||||||||||||||||||||||||||||||||||||
| children, | ||||||||||||||||||||||||||||||||||||||||
| resizable = false, | ||||||||||||||||||||||||||||||||||||||||
| defaultPosition = 'half', | ||||||||||||||||||||||||||||||||||||||||
| bottomOffset = 0, | ||||||||||||||||||||||||||||||||||||||||
| halfTopOffset = 105, | ||||||||||||||||||||||||||||||||||||||||
| fullTopOffset = 48, | ||||||||||||||||||||||||||||||||||||||||
| className, | ||||||||||||||||||||||||||||||||||||||||
| onPositionChange, | ||||||||||||||||||||||||||||||||||||||||
| }: BottomSheetProps) { | ||||||||||||||||||||||||||||||||||||||||
| const { position, isDragging, currentTranslate, sheetRef, handlers } = useBottomSheet({ | ||||||||||||||||||||||||||||||||||||||||
| defaultPosition, | ||||||||||||||||||||||||||||||||||||||||
| onPositionChange, | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const effectivePosition = resizable ? position : defaultPosition; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const getTransform = () => { | ||||||||||||||||||||||||||||||||||||||||
| if (resizable) { | ||||||||||||||||||||||||||||||||||||||||
| return effectivePosition === 'half' | ||||||||||||||||||||||||||||||||||||||||
| ? `translateY(calc(55% + ${currentTranslate}px))` | ||||||||||||||||||||||||||||||||||||||||
| : `translateY(${Math.max(0, currentTranslate)}px)`; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| return effectivePosition === 'half' ? 'translateY(55%)' : 'translateY(0)'; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const content = typeof children === 'function' ? children(effectivePosition) : children; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||||
| role="region" | ||||||||||||||||||||||||||||||||||||||||
| aria-label="Bottom Sheet" | ||||||||||||||||||||||||||||||||||||||||
| ref={sheetRef} | ||||||||||||||||||||||||||||||||||||||||
| className={clsx( | ||||||||||||||||||||||||||||||||||||||||
| 'fixed inset-x-0 z-20 flex flex-col rounded-t-3xl bg-white transition-transform duration-300 ease-out', | ||||||||||||||||||||||||||||||||||||||||
| resizable && isDragging && 'transition-none', | ||||||||||||||||||||||||||||||||||||||||
| className | ||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||
| style={{ | ||||||||||||||||||||||||||||||||||||||||
| bottom: `${bottomOffset}px`, | ||||||||||||||||||||||||||||||||||||||||
| height: | ||||||||||||||||||||||||||||||||||||||||
| effectivePosition === 'full' | ||||||||||||||||||||||||||||||||||||||||
| ? `calc(100% - ${fullTopOffset}px - ${bottomOffset}px)` | ||||||||||||||||||||||||||||||||||||||||
| : `calc(100% - ${halfTopOffset}px - ${bottomOffset}px)`, | ||||||||||||||||||||||||||||||||||||||||
| transform: getTransform(), | ||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||
| {resizable && ( | ||||||||||||||||||||||||||||||||||||||||
| <div className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing" {...handlers}> | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| <div className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing" {...handlers}> | |
| <div | |
| className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing" | |
| {...handlers} | |
| role="button" | |
| tabIndex={0} | |
| aria-label="Resize bottom sheet" | |
| onKeyDown={(event) => { | |
| if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { | |
| event.preventDefault(); | |
| const anyHandlers = handlers as any; | |
| if (typeof anyHandlers.onMouseDown === 'function') { | |
| anyHandlers.onMouseDown(event); | |
| } else if (typeof anyHandlers.onPointerDown === 'function') { | |
| anyHandlers.onPointerDown(event); | |
| } | |
| } | |
| }} | |
| > |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,9 @@ import LayersIcon from '@/assets/svg/layers.svg'; | |||||||||||||
| import LogoutIcon from '@/assets/svg/logout.svg'; | ||||||||||||||
| import UserSquareIcon from '@/assets/svg/user-square.svg'; | ||||||||||||||
| import UserIcon from '@/assets/svg/user.svg'; | ||||||||||||||
| import BottomModal from '@/components/common/BottomModal'; | ||||||||||||||
| import Card from '@/components/common/Card'; | ||||||||||||||
| import useBooleanState from '@/utils/hooks/useBooleanState'; | ||||||||||||||
| import { useMyInfo } from '../Profile/hooks/useMyInfo'; | ||||||||||||||
| import { useLogoutMutation } from './hooks/useLogout'; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -23,6 +25,8 @@ function MyPage() { | |||||||||||||
| const navigate = useNavigate(); | ||||||||||||||
| const { myInfo } = useMyInfo(); | ||||||||||||||
| const { mutate: logout } = useLogoutMutation(); | ||||||||||||||
| const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false); | ||||||||||||||
|
|
||||||||||||||
| const handleClick = (to: string) => { | ||||||||||||||
| navigate(to); | ||||||||||||||
| }; | ||||||||||||||
|
|
@@ -81,13 +85,30 @@ function MyPage() { | |||||||||||||
| <div className="text-[13px] leading-4 text-indigo-200">v1.0.0</div> | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
| <button className="bg-indigo-0 flex items-center rounded-sm px-3 py-2" onClick={() => logout()}> | ||||||||||||||
| <button className="bg-indigo-0 flex items-center rounded-sm px-3 py-2" onClick={openModal}> | ||||||||||||||
| <div className="flex items-center gap-4"> | ||||||||||||||
| <LogoutIcon /> | ||||||||||||||
| <div className="text-sm leading-4 font-semibold">로그아웃</div> | ||||||||||||||
| </div> | ||||||||||||||
| </button> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <BottomModal isOpen={isOpen} onClose={closeModal}> | ||||||||||||||
| <div className="flex flex-col gap-10 px-8 pt-7 pb-4"> | ||||||||||||||
| <div className="text-h3 text-center whitespace-pre-wrap">정말로 로그아웃 하시겠어요?</div> | ||||||||||||||
| <div> | ||||||||||||||
| <button | ||||||||||||||
| onClick={() => logout()} | ||||||||||||||
| className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white" | ||||||||||||||
| > | ||||||||||||||
| 로그아웃 | ||||||||||||||
| </button> | ||||||||||||||
| <button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400"> | ||||||||||||||
|
||||||||||||||
| <button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400"> | |
| <button | |
| type="button" | |
| onClick={closeModal} | |
| className="text-h3 w-full rounded-lg border border-indigo-200 bg-white py-3.5 text-center text-indigo-500" | |
| > |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||||||||
| import { useState } from 'react'; | ||||||||||||
| import Modal from '@/components/common/Modal'; | ||||||||||||
| import BottomModal from '@/components/common/BottomModal'; | ||||||||||||
| import useBooleanState from '@/utils/hooks/useBooleanState'; | ||||||||||||
| import { useWithdrawMutation } from '../MyPage/hooks/useWithdraw'; | ||||||||||||
| import { useMyInfo } from './hooks/useMyInfo'; | ||||||||||||
|
|
@@ -71,27 +71,24 @@ function Profile() { | |||||||||||
| 수정 완료 | ||||||||||||
| </button> | ||||||||||||
|
|
||||||||||||
| <Modal isOpen={isOpen} onClose={closeModal}> | ||||||||||||
| <div className="flex flex-col gap-5 p-5"> | ||||||||||||
| <div className="text-lg leading-8 font-bold whitespace-pre-wrap"> | ||||||||||||
| <BottomModal isOpen={isOpen} onClose={closeModal}> | ||||||||||||
| <div className="flex flex-col gap-10 px-8 pt-7 pb-4"> | ||||||||||||
| <div className="text-h3 text-center whitespace-pre-wrap"> | ||||||||||||
| 정말로 탈퇴하시겠어요?{'\n'}탈퇴 후 코넥트의 기능을 사용할 수 없어요 | ||||||||||||
| </div> | ||||||||||||
| <div className="flex justify-between gap-3"> | ||||||||||||
| <button | ||||||||||||
| onClick={closeModal} | ||||||||||||
| className="bg-indigo-25 w-full rounded-lg py-3.5 text-center text-lg leading-7 font-bold text-indigo-400" | ||||||||||||
| > | ||||||||||||
| 취소 | ||||||||||||
| </button> | ||||||||||||
| <div> | ||||||||||||
| <button | ||||||||||||
| onClick={() => withdraw()} | ||||||||||||
| className="w-full rounded-lg bg-indigo-700 py-3.5 text-center text-lg leading-7 font-bold text-white" | ||||||||||||
| className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white" | ||||||||||||
| > | ||||||||||||
| 탈퇴하기 | ||||||||||||
| </button> | ||||||||||||
| <button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400"> | ||||||||||||
|
||||||||||||
| <button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400"> | |
| <button | |
| onClick={closeModal} | |
| className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400 bg-indigo-50 border border-indigo-200" | |
| > |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The button ordering places the destructive primary action (탈퇴하기/withdraw) above the cancel action. This is contrary to common UX patterns where destructive actions should typically be placed in a less prominent position to prevent accidental clicks. Consider placing the cancel button above or making the destructive action less prominent visually.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When resizable is false, the hook still runs and manages state unnecessarily. The handlers are created but never used, which wastes resources. Consider adding a condition to skip hook initialization when resizable is false, or create a simpler version of the component for non-resizable cases.