번개 모임 웹 어플리케이션 - 검색 페이지 마크업 (+ jsx-a11y 에러 해결)

선정·2023년 5월 15일
0

파일구조

  • pages/BungaeSearch.js
    : 아래의 컴포넌트로 각 섹션 별로 분리하여 마크업하고 import 한다.

  • components/BungaeSearch/

    • LocalOptions.js : 시/도, 시/구/군 지역을 선택할 수 있는 옵션을 가진 컴포넌트 (모달)
    • SearchForm.js : 키워드 및 지역을 검색할 수 있는 검색창 컴포넌트
    • SearchedBungaeList.js : 검색 결과를 보여주는 컴포넌트


코드

  • 지역 선택은 한번에 하나의 시/도, 시/구/군까지만 선택할 수 있도록 할 것 (다중 선택 불가)
  • 모달창에서 시/도 및 시/군/구를 선택했을 때마다 페이지 내 검색창에 선택된 항목이 동적으로 보이도록 state로 관리할 것
  • 최신순, 마감임박순 정렬 전환은 url의 query string으로 구별해줄 것
  • 정렬에 해당하는 query string이 없으면 최신순으로 정렬할 것
  • 정렬 전환은 <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;


li 태그 onClick 이벤트 eslint 에러 발생 해결 (jsx-a11y)

<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 이벤트를 할당했기 때문에 발생한 에러였다.

찾아본 해결 방법은 여러가지가 있었다.


  1. 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>

  1. li 태그 대신 li 태그 내부에 button과 같은 interactive elements를 추가해 해당 요소에 onClick 이벤트 등록하기
<li
  key={idx}
  role="menuitem"
  className={idx === currentSido ? "active" : ""}
>
  <button onClick={() => onSelectSido(idx, sido)}>
  {sido}
  </button>
</li>

  1. role 속성 값 추가하기
<li
  key={idx}
  role="menuitem"
  className={idx === currentSido ? "active" : ""}
  onClick={() => onSelectSido(idx, sido)}
>
  {sido}
</li>

이외에도 방법이 더 있었지만, 그 중에서도 role을 추가하는 방식이 가장 마음에 들었다. 그 중에서도 role="menuitem"을 속성값으로 사용했다. 여러 지역 리스트 중 하나를 선택해야 하는 일종의 메뉴의 역할을 하고 있기 때문에 해당 해결 방식이 적절하다고 생각해 차용했다.

참고

profile
starter

0개의 댓글