pages/BungaeSearch.js
: 아래의 컴포넌트로 각 섹션 별로 분리하여 마크업하고 import 한다.
components/BungaeSearch/
LocalOptions.js
: 시/도, 시/구/군 지역을 선택할 수 있는 옵션을 가진 컴포넌트 (모달)SearchForm.js
: 키워드 및 지역을 검색할 수 있는 검색창 컴포넌트SearchedBungaeList.js
: 검색 결과를 보여주는 컴포넌트<ProfilePage>
를 만들 때 사용했던 <SortTab>
컴포넌트를 재사용할 것<BungaeMainPage>
및 <ProfilePage>
를 만들 때 사용했던 <BungaeListContent>
컴포넌트를 재사용 할 것pages/BungaeSearch.js
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import styled from "styled-components";
import { searchPageTabMenu as tabMenu } from "../@constants/constants";
import { dummyBungaeList } from "../@constants/dummy";
import LocalOptions from "../components/BungaeSearch/LocalOptions";
import SearchedBungaeList from "../components/BungaeSearch/SearchedBungaeList";
import SearchForm from "../components/BungaeSearch/SearchForm";
import RootPageContent from "../components/PageContent/RootPageContent";
const StyledSection = styled.section`
width: 100%;
& + & {
margin-top: 40px;
}
`;
function BungaeSearchPage() {
const [selectedLocal, setSelectedLocal] = useState({
sido: "",
sigugun: ""
});
const [localOptionsIsOpen, setLocalOptionsIsOpen] = useState(false);
const [currentSido, setCurrentSido] = useState(0);
const [currentSigugun, setCurrentSigugun] = useState(null);
const [bungaeList, setBungaeList] = useState([]);
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get("sort");
useEffect(() => {
setBungaeList(dummyBungaeList);
}, []);
// 최신순, 마감임박순 클릭 시, 그에 맞는 query string으로 변경
const switchTabHandler = (selected) => {
setSearchParams({ sort: selected });
};
// 지역 선택 모달창 open <-> close 전환
const toggleLocalOptionsHandler = () => {
setLocalOptionsIsOpen((prev) => !prev);
};
// 시/도 클릭 이벤트 핸들러
const selectSidoHandler = (idx, text) => {
setCurrentSido(idx);
setSelectedLocal({ sido: text, sigugun: "" });
setCurrentSigugun((prev) => ({ ...prev, sigugun: null }));
};
// 시/구/군 클릭 이벤트 핸들러
const selectSigugunHandler = (idx, text) => {
setCurrentSigugun(idx);
setSelectedLocal((prev) => ({ ...prev, sigugun: text }));
};
// 지역 선택 초기화 클릭 이벤트 핸들러
const resetSelectionHandler = () => {
setCurrentSido(0);
setCurrentSigugun(null);
setSelectedLocal({
sido: "",
sigugun: ""
});
};
return (
<RootPageContent>
<StyledSection>
<SearchForm
onOpen={toggleLocalOptionsHandler}
selectedLocal={selectedLocal}
/>
<LocalOptions
isOpen={localOptionsIsOpen}
onClose={toggleLocalOptionsHandler}
currentSido={currentSido}
onSelectSido={selectSidoHandler}
currentSigugun={currentSigugun}
onSelectSigugun={selectSigugunHandler}
onReset={resetSelectionHandler}
/>
</StyledSection>
<StyledSection>
<SearchedBungaeList
count={bungaeList.length}
sortBy={sort}
onSwitchTab={switchTabHandler}
tabMenu={tabMenu}
bungaeList={bungaeList}
/>
</StyledSection>
</RootPageContent>
);
}
export default BungaeSearchPage;
componenets/BungaeSearch/SearchForm.js
import styled from "styled-components";
const StyledSearchForm = styled.form`
width: 100%;
display: flex;
border-radius: 5px;
height: 62px;
`;
const KeywordSearchWrapper = styled.div`
flex-grow: 1;
display: flex;
align-items: center;
padding: 0 14px;
border: 1px solid black;
border-radius: 5px 0px 0px 5px;
.image-wrapper {
width: 14px;
height: 14px;
margin-right: 8px;
}
`;
const LocalSearchWrapper = styled(KeywordSearchWrapper).attrs(
({ onClick }) => ({ onClick })
)`
border-radius: 0px;
border-left: 0px;
> p {
font-size: ${({ theme }) => theme.fontSize.sm};
color: ${({ theme }) => theme.palette.gray5};
min-width: 120px;
}
> p.selected {
color: ${({ theme }) => theme.palette.black};
}
`;
const StyledKeywordInput = styled.input.attrs(() => ({
placeholder: "키워드를 입력해주세요"
}))`
width: 100%;
outline: none;
border: none;
font-size: 14px;
padding: 0px;
`;
const StyledSearchButton = styled.button`
outline: none;
border: 1px solid black;
border-left: 0px;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
width: 62px;
background: ${({ theme }) => theme.palette.mainMauve};
font-weight: ${({ theme }) => theme.fontWeight.semiBold};
`;
function SearchForm({ onOpen, selectedLocal }) {
const { sido, sigugun } = selectedLocal;
const localIsSelected = sido !== "" && sigugun !== "";
return (
<StyledSearchForm>
<KeywordSearchWrapper>
<div className="image-wrapper">
<img src="/images/search.svg" alt="keyword search" />
</div>
<StyledKeywordInput />
</KeywordSearchWrapper>
<LocalSearchWrapper onClick={onOpen}>
<div className="image-wrapper">
<img src="/images/map.svg" alt="map marker" />
</div>
{localIsSelected ? (
<p className="selected">{`${sido} ${sigugun}`}</p>
) : (
<p>지역을 선택해주세요</p>
)}
</LocalSearchWrapper>
<StyledSearchButton>검색</StyledSearchButton>
</StyledSearchForm>
);
}
export default SearchForm;
componenets/BungaeSearch/LocalOptions.js
import styled from "styled-components";
import { localList } from "../../@constants/constants";
import Button from "../UI/Button";
import Modal from "../UI/Modal";
const StyledHeader = styled.h1`
font-size: ${({ theme }) => theme.fontSize.lg};
font-weight: ${({ theme }) => theme.fontWeight.bold};
margin-bottom: 24px;
`;
const SyledLocalListWrapper = styled.div`
display: flex;
.list-title {
text-align: left;
margin-bottom: 6px;
font-size: ${({ theme }) => theme.fontSize.xs};
}
`;
const StyledSidoList = styled.ul`
width: 160px;
height: 140px;
overflow-y: scroll;
border: 1px solid black;
font-size: ${({ theme }) => theme.fontSize.sm};
cursor: pointer;
> li {
padding: 8px 20px;
}
> li.active {
background: ${({ theme }) => theme.palette.mainMauve};
font-weight: ${({ theme }) => theme.fontWeight.semiBold};
}
`;
const SyledSigugunList = styled(StyledSidoList)`
border-left: 0px;
`;
const StyledButtonContainer = styled.div`
display: flex;
gap: 10px;
margin-top: 24px;
`;
function LocalOptions({
isOpen,
onClose,
currentSido,
onSelectSido,
currentSigugun,
onSelectSigugun,
onReset
}) {
if (!isOpen) return null;
return (
<Modal isOpen={isOpen} onClose={onClose}>
<>
<StyledHeader>지역 선택</StyledHeader>
<SyledLocalListWrapper>
<div>
<div className="list-title">시·도</div>
<StyledSidoList>
{localList.map(({ sido }, idx) => (
<li
key={idx}
role="menuitem"
className={idx === currentSido ? "active" : ""}
onClick={() => onSelectSido(idx, sido)}
>
{sido}
</li>
))}
</StyledSidoList>
</div>
<div>
<div className="list-title">시·구·군</div>
<SyledSigugunList>
{localList[currentSido].sigugun.map((el, idx) => (
<li
key={idx}
role="menuitem"
className={idx === currentSigugun ? "active" : ""}
onClick={() => onSelectSigugun(idx, el)}
>
{el}
</li>
))}
</SyledSigugunList>
</div>
</SyledLocalListWrapper>
<StyledButtonContainer>
<Button background="gray3" color="white" fullWidth onClick={onReset}>
초기화
</Button>
<Button
background="mainViolet"
color="white"
fullWidth
onClick={onClose}
>
확인
</Button>
</StyledButtonContainer>
</>
</Modal>
);
}
export default LocalOptions;
components/BungaeSearch/SearchedBungaeList.js
import styled from "styled-components";
import BungaeListContent from "../PageContent/BungaeListContent";
import SortTab from "../UI/SortTab";
const StyledHeadingWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
> h1 {
font-size: ${({ theme }) => theme.fontSize["2xl"]};
font-weight: ${({ theme }) => theme.fontWeight.bold};
}
`;
function SearchedBungaeList({
count,
sortBy,
onSwitchTab,
tabMenu,
bungaeList
}) {
return (
<section>
<StyledHeadingWrapper>
<h1>{`번개 검색 결과 (${count})`}</h1>
<SortTab sortBy={sortBy} onSwitch={onSwitchTab} tabMenu={tabMenu} />
</StyledHeadingWrapper>
<BungaeListContent bungaeList={bungaeList} />
</section>
);
}
export default SearchedBungaeList;
<LocalOptions>
컴포넌트에서 li 태그에 onClick 이벤트 핸들러를 할당했을 때, eslint의 리액트 접근성과 관련된 검사를 하는 jsx-a11y에서 다음과 같은 에러가 발생했다.
Non-interactive elements should not be assigned mouse or keyboard event listeners jsx-a11y/no-noninteractive-element-interactions
이는 li 태그가 button이나 a 태그와 같이 상호작용하는 요소가 아님에도 onClick 이벤트를 할당했기 때문에 발생한 에러였다.
찾아본 해결 방법은 여러가지가 있었다.
eslint-disable-line
추가 - eslint 무시하기<li // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
key={idx}
role="menuitem"
className={idx === currentSido ? "active" : ""}
onClick={() => onSelectSido(idx, sido)}
>
{sido}
</li>
<li
key={idx}
role="menuitem"
className={idx === currentSido ? "active" : ""}
>
<button onClick={() => onSelectSido(idx, sido)}>
{sido}
</button>
</li>
role
속성 값 추가하기<li
key={idx}
role="menuitem"
className={idx === currentSido ? "active" : ""}
onClick={() => onSelectSido(idx, sido)}
>
{sido}
</li>
이외에도 방법이 더 있었지만, 그 중에서도 role을 추가하는 방식이 가장 마음에 들었다. 그 중에서도 role="menuitem"을 속성값으로 사용했다. 여러 지역 리스트 중 하나를 선택해야 하는 일종의 메뉴의 역할을 하고 있기 때문에 해당 해결 방식이 적절하다고 생각해 차용했다.
참고