Skip to content

Commit

Permalink
Merge pull request #49 from woowacourse-teams/feature/#33
Browse files Browse the repository at this point in the history
도시입력창 & 제안목록 컴포넌트 구현
  • Loading branch information
ashleysyheo authored Jul 18, 2023
2 parents 4b303c7 + 7c9b188 commit 05e1ddc
Show file tree
Hide file tree
Showing 18 changed files with 491 additions and 3 deletions.
1 change: 0 additions & 1 deletion frontend/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ const config: StorybookConfig = {
'@utils': path.resolve(__dirname, '../src/utils'),
};
}

const imageRule = config.module?.rules?.find((rule) => {
const test = (rule as { test: RegExp }).test;

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/api/city/getCities.ts
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;
};
3 changes: 3 additions & 0 deletions frontend/src/assets/svg/close-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/src/assets/svg/search-pin-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 frontend/src/components/common/CitySearchBar/CitySearchBar.tsx
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;
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 frontend/src/components/common/CitySuggestion/CitySuggestion.tsx
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;
1 change: 1 addition & 0 deletions frontend/src/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const END_POINTS = {
DAY_LOG_ORDER: (tripId: number, dayLogId: number) => `/trips/${tripId}/daylogs/${dayLogId}/order`,
CREATE_TRIP_ITEM: (tripId: number) => `/trips/${tripId}/items`,
CHANGE_TRIP_ITEM: (tripId: number, itemId: number) => `/trips/${tripId}/items/${itemId}`,
CITIES:'/cities'
} as const;

export const NETWORK = {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/constants/path.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const PATH = {
ROOT: '/',
TRIPS: '/trips',
CREATE_TRIP: '/trips/new',
CREATE_TRIP: '/trip-new',
EDIT_TRIP: '/trip-edit/:tripId',
TRIP: '/trip',
EXPENSE: '/trip/expense',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/constants/ui.ts
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;
75 changes: 75 additions & 0 deletions frontend/src/hooks/common/useCitySuggestion.ts
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,
};
};
Loading

0 comments on commit 05e1ddc

Please sign in to comment.