Skip to content

Commit 12e5f4a

Browse files
committed
feat(#98): 가게 검색 바텀시트 컴포넌트 생성
1 parent 5408d76 commit 12e5f4a

File tree

3 files changed

+276
-0
lines changed

3 files changed

+276
-0
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { style } from "@vanilla-extract/css";
2+
3+
import { colors } from "@/styles";
4+
5+
export const contentWrapper = style({
6+
height: "calc(100dvh - 52px)",
7+
display: "flex",
8+
flexDirection: "column",
9+
});
10+
11+
export const titleWrapper = style({
12+
display: "flex",
13+
justifyContent: "space-between",
14+
alignItems: "center",
15+
});
16+
17+
export const clearButtonWrapper = style({
18+
display: "flex",
19+
alignItems: "center",
20+
});
21+
22+
export const icon = style({
23+
color: colors.neutral[90],
24+
});
25+
26+
export const searchResultItems = style({
27+
display: "flex",
28+
flexDirection: "column",
29+
gap: "0.2rem",
30+
height: "49rem",
31+
overflowY: "auto",
32+
cursor: "pointer",
33+
});
34+
35+
export const searchResultItem = style({
36+
padding: "1.2rem 1.6rem",
37+
});
38+
39+
export const skeletonItem = style({
40+
padding: "1.2rem 1.6rem",
41+
borderRadius: "8px",
42+
});
43+
44+
export const skeletonContent = style({
45+
display: "flex",
46+
flexDirection: "column",
47+
gap: "8px",
48+
});
49+
50+
export const listVariants = {
51+
hidden: { opacity: 1 },
52+
visible: {
53+
opacity: 1,
54+
transition: { staggerChildren: 0.05 },
55+
},
56+
};
57+
58+
export const itemVariants = {
59+
hidden: { opacity: 0, y: 6 },
60+
visible: { opacity: 1, y: 0, transition: { duration: 0.25 } },
61+
};
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { AnimatePresence, motion } from "framer-motion";
5+
import { type ReactNode, useCallback, useEffect, useState } from "react";
6+
import { useForm } from "react-hook-form";
7+
import { useDebounce } from "react-simplikit";
8+
9+
import CircleCloseIcon from "@/assets/circle-close.svg";
10+
import SearchIcon from "@/assets/search.svg";
11+
import { BottomSheet } from "@/components/ui/BottomSheet";
12+
import { Button } from "@/components/ui/Button";
13+
import { Skeleton } from "@/components/ui/Skeleton";
14+
import { Text } from "@/components/ui/Text";
15+
import { TextButton } from "@/components/ui/TextButton";
16+
import { TextField } from "@/components/ui/TextField";
17+
18+
import { storeSearchQueryOptions } from "../../_api";
19+
import { type SearchStoreFormValues } from "../../_types";
20+
import * as styles from "./SearchStoreBottomSheet.css";
21+
22+
export type SearchStoreBottomSheetProps = {
23+
/** 바텀시트 열림/닫힘 상태 */
24+
open: boolean;
25+
26+
/** 바텀시트 상태 변경 핸들러 */
27+
onOpenChange: (open: boolean) => void;
28+
29+
/** 가게 선택 시 호출되는 핸들러 */
30+
onSelect: (storeName: string) => void;
31+
};
32+
33+
export const SearchStoreBottomSheet = ({
34+
open,
35+
onOpenChange,
36+
onSelect,
37+
}: SearchStoreBottomSheetProps) => {
38+
const { register, reset, watch, setValue } = useForm<SearchStoreFormValues>({
39+
defaultValues: { searchTerm: "" },
40+
mode: "onChange",
41+
});
42+
43+
const searchTermValue = watch("searchTerm");
44+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
45+
46+
// TODO: 검색 디바운스 시간 의논
47+
const debouncedSearch = useDebounce((value: string) => {
48+
setDebouncedSearchTerm(value);
49+
}, 2000);
50+
51+
const { data: list, isLoading } = useQuery(
52+
storeSearchQueryOptions(debouncedSearchTerm)
53+
);
54+
55+
const handleSearchChange = useCallback(
56+
(value: string) => {
57+
if (!value.trim()) {
58+
debouncedSearch.cancel();
59+
setDebouncedSearchTerm("");
60+
return;
61+
}
62+
debouncedSearch(value);
63+
},
64+
[debouncedSearch]
65+
);
66+
67+
const handleSelect = (name: string) => {
68+
onSelect(name);
69+
onOpenChange(false);
70+
};
71+
72+
const handleCloseClick = () => {
73+
reset();
74+
setDebouncedSearchTerm("");
75+
onOpenChange(false);
76+
};
77+
78+
const handleClearClick = () => {
79+
setValue("searchTerm", "");
80+
setDebouncedSearchTerm("");
81+
};
82+
83+
useEffect(() => {
84+
if (!open) {
85+
reset();
86+
setDebouncedSearchTerm("");
87+
debouncedSearch.cancel();
88+
}
89+
}, [open, reset, debouncedSearch]);
90+
91+
const isSearching = !!debouncedSearchTerm && isLoading;
92+
93+
return (
94+
<BottomSheet.Root open={open} onOpenChange={onOpenChange}>
95+
<BottomSheet.Content className={styles.contentWrapper}>
96+
<BottomSheet.Title className={styles.titleWrapper}>
97+
<Text typo='title2Sb' color='neutral.10'>
98+
가게 검색
99+
</Text>
100+
<TextButton
101+
variant='primary'
102+
size='medium'
103+
onClick={handleCloseClick}
104+
>
105+
취소
106+
</TextButton>
107+
</BottomSheet.Title>
108+
109+
<BottomSheet.Body>
110+
<TextField
111+
{...register("searchTerm", {
112+
onChange: e => handleSearchChange(e.target.value),
113+
})}
114+
placeholder='가게명을 입력해주세요'
115+
rightAddon={
116+
searchTermValue ? (
117+
<button
118+
className={styles.clearButtonWrapper}
119+
onClick={handleClearClick}
120+
type='button'
121+
>
122+
<CircleCloseIcon
123+
width={22}
124+
height={22}
125+
className={styles.icon}
126+
/>
127+
</button>
128+
) : (
129+
<SearchIcon width={20} height={20} />
130+
)
131+
}
132+
/>
133+
134+
<AnimatePresence mode='wait'>
135+
{isSearching ? (
136+
<motion.ul
137+
key='loading'
138+
className={styles.searchResultItems}
139+
initial='hidden'
140+
animate='visible'
141+
exit={{ opacity: 0 }}
142+
variants={styles.listVariants}
143+
>
144+
{Array.from({ length: 6 }).map((_, index) => (
145+
<SearchResultItemSkeleton key={index} />
146+
))}
147+
</motion.ul>
148+
) : (
149+
<motion.ul
150+
className={styles.searchResultItems}
151+
initial='hidden'
152+
animate='visible'
153+
variants={styles.listVariants}
154+
>
155+
{list?.stores?.map(store => (
156+
<SearchResultItemLayout
157+
key={store.kakaoId}
158+
onSelect={handleSelect}
159+
storeName={store.name}
160+
>
161+
<Text typo='body1Sb'>{store.name}</Text>
162+
<Text typo='label2Sb' color='neutral.50'>
163+
{store.address}
164+
</Text>
165+
</SearchResultItemLayout>
166+
))}
167+
</motion.ul>
168+
)}
169+
</AnimatePresence>
170+
</BottomSheet.Body>
171+
172+
<BottomSheet.Footer>
173+
<Button size='fullWidth' onClick={handleCloseClick}>
174+
확인
175+
</Button>
176+
</BottomSheet.Footer>
177+
</BottomSheet.Content>
178+
</BottomSheet.Root>
179+
);
180+
};
181+
182+
const SearchResultItemLayout = ({
183+
onSelect,
184+
storeName,
185+
className = styles.searchResultItem,
186+
children,
187+
}: {
188+
children: ReactNode;
189+
onSelect?: (name: string) => void;
190+
storeName?: string;
191+
className?: string;
192+
}) => (
193+
<motion.li
194+
className={className}
195+
variants={styles.itemVariants}
196+
onClick={onSelect && storeName ? () => onSelect(storeName) : undefined}
197+
>
198+
{children}
199+
</motion.li>
200+
);
201+
202+
const SearchResultItemSkeleton = () => {
203+
return (
204+
<SearchResultItemLayout className={styles.skeletonItem}>
205+
<div className={styles.skeletonContent}>
206+
<Skeleton width='60%' height={20} radius={4} />
207+
<Skeleton width='80%' height={16} radius={4} />
208+
</div>
209+
</SearchResultItemLayout>
210+
);
211+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {
2+
SearchStoreBottomSheet,
3+
type SearchStoreBottomSheetProps,
4+
} from "./SearchStoreBottomSheet";

0 commit comments

Comments
 (0)