diff --git a/src/shared/hooks/use-modal-context.ts b/src/shared/hooks/use-modal-context.ts new file mode 100644 index 0000000..ae94734 --- /dev/null +++ b/src/shared/hooks/use-modal-context.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; + +interface ModalContextValue { + onClose: () => void; +} + +export const ModalContext = createContext(null); + +export function useModalContext() { + const context = useContext(ModalContext); + + if (!context) { + throw new Error('useModalContext must be used within Modal.Root'); + } + + return context; +} diff --git a/src/shared/ui/input.tsx b/src/shared/ui/input.tsx index cb0b325..93596dd 100644 --- a/src/shared/ui/input.tsx +++ b/src/shared/ui/input.tsx @@ -1,6 +1,6 @@ import type { InputHTMLAttributes, ReactNode } from 'react'; -export type InputSize = 'sm' | 'md' | 'lg'; +export type InputSize = 'sm' | 'md' | 'lg' | 'ssm'; interface InputProps extends InputHTMLAttributes { inputSize: InputSize; @@ -28,6 +28,7 @@ const baseInput = ` `; const sizeStyles: Record = { + ssm: 'w-[22.4rem] h-[4.8rem]', sm: 'w-[26.3rem] px-[1.2rem]', md: 'w-[26.7rem]', lg: 'w-[32.7rem]', diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts new file mode 100644 index 0000000..049553b --- /dev/null +++ b/src/shared/utils/date.ts @@ -0,0 +1,17 @@ +export function generateFutureDates(days = 30) { + const result: string[] = []; + const today = new Date(); + + for (let i = 0; i <= days; i++) { + const d = new Date(today); + d.setDate(today.getDate() + i); + + const month = String(d.getMonth() + 1).padStart(2, '0'); + const date = String(d.getDate()).padStart(2, '0'); + const day = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()]; + + result.push(`${month}.${date} (${day})`); + } + + return result; +} diff --git a/src/widgets/create/modal/contents/modal-location-search.tsx b/src/widgets/create/modal/contents/modal-location-search.tsx new file mode 100644 index 0000000..c0a3878 --- /dev/null +++ b/src/widgets/create/modal/contents/modal-location-search.tsx @@ -0,0 +1,27 @@ +import { FloatingActionButton } from '@shared/ui/floatingActionButton'; +import Input from '@shared/ui/input'; +import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; + +interface ModalLocationSearchProps { + value: string; + onChange: (value: string) => void; +} + +export function ModalLocationSearch({ + value, + onChange, +}: ModalLocationSearchProps) { + return ( +
+ onChange(e.target.value)} + placeholder="예) 역삼동" + /> + } + /> +
+ ); +} diff --git a/src/widgets/create/modal/contents/wheel/date-time-picker.tsx b/src/widgets/create/modal/contents/wheel/date-time-picker.tsx new file mode 100644 index 0000000..6b4b7fe --- /dev/null +++ b/src/widgets/create/modal/contents/wheel/date-time-picker.tsx @@ -0,0 +1,78 @@ +import { useMemo, useState } from 'react'; +import { Wheel } from './wheel'; +import { generateFutureDates } from '@shared/utils/date'; + +interface DateTimePickerProps { + days?: number; // 미래 며칠까지 보여줄지 + onChange?: (result: { + dateText: string; + hour: string; + minute: string; + }) => void; +} + +export function DateTimePicker({ days = 30, onChange }: DateTimePickerProps) { + const dates = useMemo(() => generateFutureDates(days), [days]); + const hours = useMemo( + () => Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')), + [], + ); + const minutes = useMemo( + () => Array.from({ length: 6 }, (_, i) => String(i * 10).padStart(2, '0')), + [], + ); + + const [dateIndex, setDateIndex] = useState(0); + const [hourIndex, setHourIndex] = useState(0); + const [minuteIndex, setMinuteIndex] = useState(0); + + const emit = (next?: Partial<{ d: number; h: number; m: number }>) => { + const d = next?.d ?? dateIndex; + const h = next?.h ?? hourIndex; + const m = next?.m ?? minuteIndex; + + const result = { + dateText: dates[d] ?? dates[0], + hour: hours[h] ?? hours[0], + minute: minutes[m] ?? minutes[0], + }; + + console.log('선택된 값:', result); + + onChange?.(result); + }; + + return ( +
+ { + setDateIndex(i); + emit({ d: i }); + }} + /> +
+ { + setHourIndex(i); + emit({ h: i }); + }} + /> + { + setMinuteIndex(i); + emit({ m: i }); + }} + /> +
+
+ ); +} diff --git a/src/widgets/create/modal/contents/wheel/wheel.tsx b/src/widgets/create/modal/contents/wheel/wheel.tsx new file mode 100644 index 0000000..b1842a3 --- /dev/null +++ b/src/widgets/create/modal/contents/wheel/wheel.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { cn } from '@shared/utils/cn'; + +interface WheelProps { + items: string[]; + value: number; // 선택된 index + onChange: (index: number) => void; + className?: string; +} + +const ITEM_HEIGHT = 40; // 4rem + +export function Wheel({ items, value, onChange, className }: WheelProps) { + const listClass = useMemo( + () => + cn( + 'h-full overflow-y-scroll snap-y snap-mandatory text-center', + '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + ), + [], + ); + + return ( +
+ {/* 중앙 선택 영역 */} +
+ + {/* 위/아래 흐림 */} +
+
+ +
    { + const index = Math.round(e.currentTarget.scrollTop / ITEM_HEIGHT); + onChange(index); + }} + > + {items.map((item, index) => ( +
  • + {item} +
  • + ))} +
+
+ ); +} diff --git a/src/widgets/create/modal/modal.tsx b/src/widgets/create/modal/modal.tsx new file mode 100644 index 0000000..a40c877 --- /dev/null +++ b/src/widgets/create/modal/modal.tsx @@ -0,0 +1,104 @@ +import { ModalContext, useModalContext } from '@shared/hooks/use-modal-context'; +import { Button } from '@shared/ui/button'; +import { cn } from '@shared/utils/cn'; +import { cva } from 'class-variance-authority'; +import type { ReactNode } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} + +function Overlay() { + const { onClose } = useModalContext(); + + return ( +
+ ); +} + +function Root({ isOpen, onClose, children }: ModalProps) { + if (!isOpen) return null; + + return ( + +
+ {children} +
+
+ ); +} + +const containerVariants = cva('w-[30rem] bg-white rounded-[16px] z-50', { + variants: { + size: { + sm: 'h-[33.5rem]', + md: 'h-[40.4rem]', + }, + }, +}); + +function Container({ + size, + children, +}: { + size?: 'sm' | 'md'; + children: ReactNode; +}) { + return
{children}
; +} + +function Header({ + title, + rightAction, +}: { + title: string; + rightAction?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ +
+
+ ); +} + +interface FooterProps { + label?: string; + onConfirm: () => void; +} + +function Footer({ label = '확인', onConfirm }: FooterProps) { + return ( +
+ +
+ ); +} + +function Content({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +const Modal = { + Root, + Overlay, + Container, + Header, + Content, + Footer, +}; + +export default Modal; diff --git a/src/widgets/main/bottom-sheet/contents/map/location-search.tsx b/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx similarity index 89% rename from src/widgets/main/bottom-sheet/contents/map/location-search.tsx rename to src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx index 67f6b3e..faacf25 100644 --- a/src/widgets/main/bottom-sheet/contents/map/location-search.tsx +++ b/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx @@ -9,7 +9,7 @@ interface LocationSearchProps { export function LocationSearch({ value, onChange }: LocationSearchProps) { return ( -
+
} + icon={} />
);