모바일 필터 바텀시트 UI 적용
- FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI - 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용 - 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시 - slide-up 애니메이션 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
112
frontend/src/components/FilterSheet.tsx
Normal file
112
frontend/src/components/FilterSheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
export interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface FilterSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
options: FilterOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
// Group options by group field
|
||||
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
|
||||
const key = opt.group || "";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(opt);
|
||||
return acc;
|
||||
}, {});
|
||||
const groups = Object.keys(grouped);
|
||||
|
||||
const handleSelect = (v: string) => {
|
||||
onChange(v);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-2 pb-1 shrink-0">
|
||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
|
||||
{/* 전체(초기화) */}
|
||||
<button
|
||||
onClick={() => handleSelect("")}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
|
||||
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span>전체</span>
|
||||
{!value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||
</button>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group}>
|
||||
{group && (
|
||||
<div className="px-4 py-2 text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{grouped[group].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
|
||||
value === opt.value
|
||||
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user