Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/shared/hooks/use-modal-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createContext, useContext } from 'react';

interface ModalContextValue {
onClose: () => void;
}

export const ModalContext = createContext<ModalContextValue | null>(null);

export function useModalContext() {
const context = useContext(ModalContext);

if (!context) {
throw new Error('useModalContext must be used within Modal.Root');
}

return context;
}
3 changes: 2 additions & 1 deletion src/shared/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement> {
inputSize: InputSize;
Expand Down Expand Up @@ -28,6 +28,7 @@ const baseInput = `
`;

const sizeStyles: Record<InputSize, string> = {
ssm: 'w-[22.4rem] h-[4.8rem]',
sm: 'w-[26.3rem] px-[1.2rem]',
md: 'w-[26.7rem]',
lg: 'w-[32.7rem]',
Expand Down
17 changes: 17 additions & 0 deletions src/shared/utils/date.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions src/widgets/create/modal/contents/modal-location-search.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center h-[9.2rem] gap-[0.8rem]">
<Input
inputSize={'ssm'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="예) 역삼동"
/>
<FloatingActionButton
icon={<LocationIcon width={'2rem'} height={'2rem'} />}
/>
</div>
);
}
78 changes: 78 additions & 0 deletions src/widgets/create/modal/contents/wheel/date-time-picker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-[21.7rem] items-center justify-center gap-[2.8rem]">
<Wheel
className="w-[10.8rem]"
items={dates}
value={dateIndex}
onChange={(i) => {
setDateIndex(i);
emit({ d: i });
}}
/>
<div className="flex gap-[1.6rem]">
<Wheel
className="w-[4rem]"
items={hours}
value={hourIndex}
onChange={(i) => {
setHourIndex(i);
emit({ h: i });
}}
/>
<Wheel
className="w-[4rem]"
items={minutes}
value={minuteIndex}
onChange={(i) => {
setMinuteIndex(i);
emit({ m: i });
}}
/>
</div>
</div>
);
}
53 changes: 53 additions & 0 deletions src/widgets/create/modal/contents/wheel/wheel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('relative h-[10.4rem] overflow-hidden', className)}>
{/* 중앙 선택 영역 */}
<div className="pointer-events-none absolute top-1/2 left-0 right-0 h-[4rem] -translate-y-1/2 border-y border-gray-200" />

{/* 위/아래 흐림 */}
<div className="pointer-events-none absolute top-0 h-[4rem] w-full bg-gradient-to-b from-white to-transparent" />
<div className="pointer-events-none absolute bottom-0 h-[4rem] w-full bg-gradient-to-t from-white to-transparent" />

<ul
className={cn(listClass, 'pt-[3.2rem] pb-[3.2rem]')}
onScroll={(e) => {
const index = Math.round(e.currentTarget.scrollTop / ITEM_HEIGHT);
onChange(index);
}}
>
{items.map((item, index) => (
<li
key={`${item}-${index}`}
className={cn(
'h-[4rem] flex items-center typo-body1 justify-center snap-center transition-all',
index === value ? 'text-gray-950' : 'text-gray-300',
)}
>
{item}
</li>
))}
</ul>
</div>
);
}
104 changes: 104 additions & 0 deletions src/widgets/create/modal/modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
onClick={onClose}
className="fixed inset-0 bg-black/30 backdrop-blur"
/>
);
}

function Root({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;

return (
<ModalContext.Provider value={{ onClose }}>
<div className="fixed inset-0 z-40 flex items-center justify-center">
{children}
</div>
</ModalContext.Provider>
);
}

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 <div className={cn(containerVariants({ size }))}>{children}</div>;
}

function Header({
title,
rightAction,
}: {
title: string;
rightAction?: React.ReactNode;
}) {
return (
<div className="flex flex-col">
<div className="flex h-[5rem] items-center justify-center pt-[2.1rem] relative">
<p className="typo-h3">{title}</p>
<button className="absolute typo-body3 text-gray-950 right-[1.2rem]">
{rightAction}
</button>
</div>
</div>
);
}

interface FooterProps {
label?: string;
onConfirm: () => void;
}

function Footer({ label = '확인', onConfirm }: FooterProps) {
return (
<div className="flex justify-center">
<Button size="sm" onClick={onConfirm}>
{label}
</Button>
</div>
);
}

function Content({ children }: { children: ReactNode }) {
return (
<div className="flex flex-1 justify-center items-center">{children}</div>
);
}

const Modal = {
Root,
Overlay,
Container,
Header,
Content,
Footer,
};

export default Modal;
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ interface LocationSearchProps {

export function LocationSearch({ value, onChange }: LocationSearchProps) {
return (
<div className="flex items-center justify-center h-[9.2rem] [gap-[1.6rem]">
<div className="flex items-center justify-center h-[9.2rem] gap-[1.6rem]">
<Input
inputSize={'sm'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="동을 입력해주세요. 예) 역삼동"
/>
<FloatingActionButton
icon={<LocationIcon width={'1.83rem'} height={'1.83rem'} />}
icon={<LocationIcon width={'2rem'} height={'2rem'} />}
/>
</div>
);
Expand Down