-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
도시입력창 & 제안목록 컴포넌트 구현
- Loading branch information
Showing
18 changed files
with
491 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { END_POINTS } from '@constants/api'; | ||
|
||
import { axiosInstance } from '@api/axiosInstance'; | ||
|
||
import type { City } from '@components/common/CitySearchBar/CitySearchBar'; | ||
|
||
export const getCities = async () => { | ||
const { data } = await axiosInstance.get<City[]>(END_POINTS.CITIES); | ||
return data; | ||
}; |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions
55
frontend/src/components/common/CitySearchBar/CitySearchBar.style.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { css } from '@emotion/react'; | ||
import { Theme } from 'hang-log-design-system'; | ||
|
||
export const containerStyling = css({ | ||
width: '500px', | ||
margin: '0 auto', | ||
}); | ||
|
||
export const wrapperStyling = css({ | ||
display: 'flex', | ||
alignItems: 'center', | ||
gap: Theme.spacer.spacing1, | ||
|
||
minHeight: '60px', | ||
padding: Theme.spacer.spacing2, | ||
border: `1px solid ${Theme.color.gray100}`, | ||
borderRadius: Theme.borderRadius.small, | ||
|
||
backgroundColor: Theme.color.gray100, | ||
}); | ||
|
||
export const searchPinIconStyling = css({ | ||
position: 'fixed', | ||
}); | ||
|
||
export const inputStyling = css({ | ||
width: 'fit-content', | ||
}); | ||
|
||
export const tagListStyling = css({ | ||
display: 'flex', | ||
flexWrap: 'wrap', | ||
gap: Theme.spacer.spacing1, | ||
|
||
width: '90%', | ||
marginLeft: Theme.spacer.spacing2, | ||
}); | ||
|
||
export const badgeStyling = css({ | ||
display: 'flex', | ||
alignItems: 'center', | ||
gap: Theme.spacer.spacing2, | ||
}); | ||
|
||
export const closeIconStyling = css({ | ||
width: '8px', | ||
height: '8px', | ||
|
||
cursor: 'pointer', | ||
|
||
path: { | ||
stroke: Theme.color.blue700, | ||
strokeWidth: '2px', | ||
}, | ||
}); |
104 changes: 104 additions & 0 deletions
104
frontend/src/components/common/CitySearchBar/CitySearchBar.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import CloseIcon from '@assets/svg/close-icon.svg'; | ||
import SearchPinIcon from '@assets/svg/search-pin-icon.svg'; | ||
import { Badge, Input, Menu, useOverlay } from 'hang-log-design-system'; | ||
import { useRef, useState } from 'react'; | ||
import type { FormEvent } from 'react'; | ||
|
||
import { useCityTags } from '@hooks/common/useCityTags'; | ||
|
||
import { | ||
badgeStyling, | ||
closeIconStyling, | ||
containerStyling, | ||
inputStyling, | ||
tagListStyling, | ||
wrapperStyling, | ||
} from '@components/common/CitySearchBar/CitySearchBar.style'; | ||
import CitySuggestion from '@components/common/CitySuggestion/CitySuggestion'; | ||
|
||
export interface City { | ||
id: number; | ||
name: string; | ||
} | ||
|
||
interface CitySearchBarProps { | ||
initialCityTags: City[]; | ||
} | ||
|
||
const CitySearchBar = ({ initialCityTags }: CitySearchBarProps) => { | ||
const [queryWord, setQueryWord] = useState(''); | ||
const { cityTags, addCityTag, deleteCityTag } = useCityTags(initialCityTags); | ||
const { isOpen: isSuggestionOpen, open: openSuggestion, close: closeSuggestion } = useOverlay(); | ||
const inputRef = useRef<HTMLInputElement>(null); | ||
|
||
const handleInputChange = (e: FormEvent<HTMLInputElement>) => { | ||
const word = e.currentTarget.value; | ||
setQueryWord(word); | ||
|
||
openSuggestion(); | ||
}; | ||
|
||
const handleSuggestionClick = (selectedCity: City) => { | ||
addCityTag(selectedCity); | ||
resetAll(); | ||
}; | ||
|
||
const resetAll = () => { | ||
setQueryWord(''); | ||
focusInput(); | ||
closeSuggestion(); | ||
}; | ||
|
||
const handleDeleteButtonClick = (selectedCity: City) => () => { | ||
deleteCityTag(selectedCity); | ||
focusInput(); | ||
}; | ||
|
||
const focusInput = () => { | ||
inputRef.current?.focus(); | ||
}; | ||
|
||
const handleInputFocus = () => { | ||
if (queryWord) { | ||
openSuggestion(); | ||
} | ||
}; | ||
|
||
const CityTags = () => | ||
cityTags.map((cityTag) => ( | ||
<Badge key={cityTag.id} css={badgeStyling}> | ||
{cityTag.name} | ||
<CloseIcon | ||
aria-label="삭제 아이콘" | ||
css={closeIconStyling} | ||
onClick={handleDeleteButtonClick(cityTag)} | ||
/> | ||
</Badge> | ||
)); | ||
|
||
return ( | ||
<Menu closeMenu={closeSuggestion}> | ||
<div css={containerStyling} onClick={focusInput}> | ||
<div css={wrapperStyling}> | ||
<SearchPinIcon aria-label="지도표시 아이콘" /> | ||
<div css={tagListStyling}> | ||
<CityTags /> | ||
<Input | ||
placeholder={cityTags.length ? '' : '방문 도시를 입력해주세요'} | ||
value={queryWord} | ||
onChange={handleInputChange} | ||
onFocus={handleInputFocus} | ||
ref={inputRef} | ||
css={inputStyling} | ||
/> | ||
</div> | ||
</div> | ||
{isSuggestionOpen && ( | ||
<CitySuggestion queryWord={queryWord} onItemSelect={handleSuggestionClick} /> | ||
)} | ||
</div> | ||
</Menu> | ||
); | ||
}; | ||
|
||
export default CitySearchBar; |
23 changes: 23 additions & 0 deletions
23
frontend/src/components/common/CitySuggestion/CitySuggestion.style.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { css } from '@emotion/react'; | ||
import { Theme } from 'hang-log-design-system'; | ||
|
||
export const suggestionContainerStyling = css({ | ||
width: '500px', | ||
maxHeight: '300px', | ||
|
||
overflowY: 'auto', | ||
overflowX: 'hidden', | ||
|
||
transform: 'translateY(0)', | ||
}); | ||
|
||
export const getSuggestionItemStyling = (isFocused: boolean) => | ||
css({ | ||
backgroundColor: isFocused ? Theme.color.gray200 : Theme.color.white, | ||
}); | ||
|
||
export const emptyTextStyling = css({ | ||
margin: Theme.spacer.spacing2, | ||
|
||
color: Theme.color.gray500, | ||
}); |
85 changes: 85 additions & 0 deletions
85
frontend/src/components/common/CitySuggestion/CitySuggestion.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
import { | ||
MenuList as SuggestionList, | ||
MenuItem as SuggestionsItem, | ||
Text, | ||
} from 'hang-log-design-system'; | ||
import { useEffect, useRef } from 'react'; | ||
|
||
import { useCitySuggestion } from '@hooks/common/useCitySuggestion'; | ||
|
||
import { City } from '@components/common/CitySearchBar/CitySearchBar'; | ||
import { | ||
emptyTextStyling, | ||
getSuggestionItemStyling, | ||
suggestionContainerStyling, | ||
} from '@components/common/CitySuggestion/CitySuggestion.style'; | ||
|
||
interface SuggestionProps { | ||
queryWord: string; | ||
onItemSelect: (city: City) => void; | ||
} | ||
|
||
const CitySuggestion = ({ queryWord, onItemSelect }: SuggestionProps) => { | ||
const { suggestions, focusedSuggestionIndex, isFocused, setNewSuggestions, focusSuggestion } = | ||
useCitySuggestion({ | ||
onItemSelect, | ||
}); | ||
const listRef = useRef<HTMLDivElement>(null); | ||
const itemRef = useRef<HTMLLIElement>(null); | ||
|
||
useEffect(() => { | ||
setNewSuggestions(queryWord); | ||
}, [queryWord]); | ||
|
||
const handleItemClick = (suggestion: City) => () => { | ||
onItemSelect(suggestion); | ||
}; | ||
|
||
const handleItemMouseHover = (index: number) => () => { | ||
focusSuggestion(index); | ||
}; | ||
|
||
const scrollToFocusedSuggestion = () => { | ||
const list = listRef.current; | ||
const focusedItem = itemRef.current; | ||
|
||
if (list && focusedItem) { | ||
const listRect = list.getBoundingClientRect(); | ||
const focusedItemRect = focusedItem.getBoundingClientRect(); | ||
|
||
const scrollOffset = | ||
focusedItemRect.top - listRect.top - listRect.height / 2 + focusedItemRect.height / 2; | ||
|
||
list.scrollTo({ | ||
top: list.scrollTop + scrollOffset, | ||
behavior: 'smooth', | ||
}); | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
scrollToFocusedSuggestion(); | ||
}, [focusedSuggestionIndex]); | ||
|
||
return ( | ||
<SuggestionList css={suggestionContainerStyling} ref={listRef}> | ||
{suggestions.length ? ( | ||
suggestions.map((city, index) => ( | ||
<SuggestionsItem | ||
key={city.id} | ||
onClick={handleItemClick(city)} | ||
onMouseEnter={handleItemMouseHover(index)} | ||
css={getSuggestionItemStyling(isFocused(index))} | ||
ref={isFocused(index) ? itemRef : null} | ||
> | ||
{city.name} | ||
</SuggestionsItem> | ||
)) | ||
) : ( | ||
<Text css={emptyTextStyling}>검색어에 해당하는 도시가 없습니다.</Text> | ||
)} | ||
</SuggestionList> | ||
); | ||
}; | ||
|
||
export default CitySuggestion; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
export const CITY_TAG_MAX_LENGTH = 15; | ||
|
||
export const TRIP_ITEM_LIST_SKELETON_LENGTH = 3; | ||
|
||
export const STAR_RATING_LENGTH = 5; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { useQuery } from '@tanstack/react-query'; | ||
import { useEffect, useState } from 'react'; | ||
|
||
import { makeRegexByCho } from '@utils/cityFilter'; | ||
|
||
import { getCities } from '@api/city/getCities'; | ||
|
||
import { City } from '@components/common/CitySearchBar/CitySearchBar'; | ||
|
||
export const useCitySuggestion = ({ onItemSelect }: { onItemSelect: (item: City) => void }) => { | ||
const { data: cities } = useQuery<City[]>(['city'], getCities); | ||
const [suggestions, setSuggestions] = useState<City[]>([]); | ||
const [focusedSuggestionIndex, setFocusedSuggestionIndex] = useState(-1); | ||
|
||
const setNewSuggestions = (word: string) => { | ||
const regex = makeRegexByCho(word); | ||
|
||
if (cities) { | ||
const filteredSuggestions = cities.filter(({ name }) => regex.test(name)); | ||
setSuggestions(filteredSuggestions); | ||
} | ||
}; | ||
|
||
const focusUpperSuggestion = () => { | ||
setFocusedSuggestionIndex((prevIndex) => | ||
prevIndex > 0 ? prevIndex - 1 : suggestions.length - 1 | ||
); | ||
}; | ||
|
||
const focusLowerSuggestion = () => { | ||
setFocusedSuggestionIndex((prevIndex) => | ||
prevIndex < suggestions.length - 1 ? prevIndex + 1 : 0 | ||
); | ||
}; | ||
|
||
const focusSuggestion = (index: number) => { | ||
setFocusedSuggestionIndex(index); | ||
}; | ||
|
||
const isFocused = (index: number) => { | ||
return index === focusedSuggestionIndex; | ||
}; | ||
|
||
const handleKeyPress = (e: globalThis.KeyboardEvent) => { | ||
if (e.key === 'ArrowUp') { | ||
focusUpperSuggestion(); | ||
} | ||
|
||
if (e.key === 'ArrowDown') { | ||
focusLowerSuggestion(); | ||
} | ||
|
||
if (e.key === 'Enter') { | ||
if (focusedSuggestionIndex >= 0) { | ||
onItemSelect(suggestions[focusedSuggestionIndex]); | ||
} | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
window.addEventListener('keyup', handleKeyPress); | ||
|
||
return () => window.removeEventListener('keyup', handleKeyPress); | ||
}, []); | ||
|
||
return { | ||
suggestions, | ||
focusedSuggestionIndex, | ||
setNewSuggestions, | ||
focusLowerSuggestion, | ||
focusSuggestion, | ||
focusUpperSuggestion, | ||
isFocused, | ||
}; | ||
}; |
Oops, something went wrong.