일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 백준구현
- 백준골드
- 포이마웹
- HTML5
- 프로그래머스
- JS프로그래머스
- 자바스크립트
- HTML
- 백준nodejs
- 코딩테스트
- JS
- 코테
- 백준구현문제
- 안드로이드 스튜디오
- 백준
- 리액트커뮤니티
- 백준알고리즘
- 리액트댓글기능
- CSS
- 프로그래머스코테
- dp알고리즘
- css기초
- 알고리즘
- 다이나믹프로그래밍
- 익스프레스
- 몽고DB
- js코테
- 프로그래머스JS
- 리액트
- 백준js
- Today
- Total
개발새발 로그
React - TabMenu 기능을 만들 때 어떻게 해야할까? 본문
📖들어가며...
아코디언 기능을 만들어보고나서 이제는 TabMenu 기능을 만들어보려고 한다.
사실 TabMenu기능은 아코디언 기능과 매우 유사하다.
열고 닫을 때 처리를 해야하고, 현재 보여줘야할 요소를 상태로 관리하면 된다.
그럼 저번 아코디언 기능을 만들 때와 같이 다양한 방식으로 TabMenu를 구현해보자.
🤔TabMenu를 만든다고 한다면..
처음에 TabMenu 기능을 만든다고 한다면 아래와 같이 만들 수 있다.
const TabMenu1 = () => {
//현재 선택한 탭 요소
const [currentId, setCurrentId] = useState<string>(data[0].id)
//탭 요소 클릭 이벤트
const toggleItem = (id: string) => () => {
setCurrentId(id)
}
//탭 요소 클릭으로 상태가 변할 때 마다 더미데이터에서 해당되는 description을 찾는다.
const currentDescription =
data.find(item => item.id === currentId)?.description || ''
return (
<>
<TabContainer>
<TabUl>
{data.map(d => (
<TabItem
{...d}
key={d.id}
current={currentId === d.id}
toggle={toggleItem(d.id)}
/>
))}
</TabUl>
<TabDescription>{currentDescription}</TabDescription>
</TabContainer>
</>
)
}
현재 선택한 탭 요소를 상태로 관리하고, Tab을 클릭하면 나오는 description요소는 불러오는 data에서 find()를 통해 리-렌더링 될 때마다 찾아오는 것이다.
이 코드는
내부 상태를 관리 + data요소에 find()메서드 호출을 하고 있다.
리-렌더링 될 때마다 find()를 통해서 데이터를 찾는 것은 좋지 않아 보인다.
그럼 이를 해결한다면 어떻게 해야할까?
🤔Tab의 Description을 모두 나열해놓자
그럼 Tab을 눌렀을 때 나오는 Description들을 모두 나열해놓으면된다.
Tab1, Tab2, Tab3... 으로 Tab을 모두 나열한 뒤에
Tab1_description, Tab2_description... 을 나열한다.
그리고 description부분은 css처리 혹은 조건부 렌더링을 통해서 화면을 통해 보여주면 될 것이다.
const TabMenu2 = () => {
//현재 선택한 탭 요소
const [currentId, setCurrentId] = useState<string>(data[0].id)
//탭 요소 클릭 이벤트
const toggleItem = (id: string) => () => {
setCurrentId(id)
}
return (
<>
<TabContainer>
<TabUl>
{data.map(d => (
<TabItem
{...d}
key={d.id}
current={currentId === d.id}
toggle={toggleItem(d.id)}
/>
))}
</TabUl>
{data.map(d => (
<TabMenu2Description
key={d.id}
current={currentId === d.id}>
{d.description}
</TabMenu2Description>
))}
</TabContainer>
</>
)
}
//...
//styled-component를 이용해 current를 props로 받고, 해당되는 description만 display:block 처리
const TabMenu2Description = styled.div<DescriptionProps>`
padding: 20px;
background-color: #ffffff;
display: ${props => (props.current ? 'block' : 'none')};
`
이를 통해서 첫 번째 방법에서의 문제점을 해결했다.
하지만 이 방법 또한 문제가 있다.
아까 말했듯이 현재 탭의 Title 부분과 Description 부분이 각각 map()으로 나열되고 있다.
그럼 map을 두 번사용해야하는 문제점이 있을 것이고,
Tab1, Tab2, Tab3... 으로 Tab을 모두 나열한 뒤에
Tab1_description, Tab2_description... 을 나열된다고 했는데
이를 스크린 리더가 읽는다면 위 순서 그래도 읽게 될 것이다.
그럼 우리가 의도한 순서가 아니다.
우리는 Tab1, Tab1_description, Tab2, Tab2_description이 의도한 순서일 것이다.
이를 해결하려면 어떻게 해야할까?
🤔웹 접근성을 위한 구조로 TabMenu 구현
일단 구조를 나열할 때 위에서 말했듯이 Tab1, Tab1_description, Tab2, Tab2_description.. 와 같은 구조로 나열해야 할 것이다.
이렇게 구조를 짜게 되면 스크린리더는 우리가 의도한 순서대로 읽을 것이고, 이는 웹 접근성에 좋은 영향을 줄 것이다.
코드를 보면 아래와 같다.
//TabItem 컴포넌트에 Title과 Description, current가 들어간다.
//Description부분은 current값으로 CSS에서 숨김/보임 처리가 된다.
const TabItem = ({
id,
title,
description,
current,
toggle
}: {
id: string
title: string
description: string
current: boolean
toggle: () => void
}) => {
return (
<TabMenu3ItemLi key={id}>
<TabMenu3Title
current={current}
onClick={toggle}>
{title}
</TabMenu3Title>
<TabMenu3Description current={current}>{description}</TabMenu3Description>
</TabMenu3ItemLi>
)
}
const TabMenu3 = () => {
const [currentId, setCurrentId] = useState<string>(data[0].id)
const toggleItem = (id: string) => () => {
setCurrentId(id)
}
return (
<>
<TabMenu3Ul>
{data.map(d => (
<TabItem
{...d}
key={d.id}
current={currentId === d.id}
toggle={toggleItem(d.id)}
/>
))}
</TabMenu3Ul>
</>
)
}
이 방식에서 중요한 부분은 Description 부분의 css가 어떻게 되어있는지다.
아래 styled-component로 만든 코드를 보자.
export const TabMenu3Ul = styled.ul`
display: flex;
flex-wrap: nowrap;
//...
`
export const TabMenu3ItemLi = styled.li`
flex: 1;
overflow: hidden;
//...
`
export const TabMenu3Title = styled.div<TabProps>`
background-color: ${props => (props.current ? '#a5d6a7' : 'transparent')};
//...
`
//현재 Description 부분은 모두 absolute로 처리되어있다.
//즉 모든 Description이 겹쳐있는 상태이고, 클릭한 Tab의 Description만 출력되고 있는 것이다.
export const TabMenu3Description = styled.div<TabProps>`
position: absolute;
top: 100%;
left: 0;
width: 100%;
display: ${props => (props.current ? 'block' : 'none')};
//...
`
위처럼 모든 Description을 겹치게 둔 상태에서 display속성으로 숨김/보임 처리를 했다.
왜냐하면 탭의 구조가 아래 이미지 처럼 구성되어야 하는데
우리가 원하는 DOM트리 구조는 그렇지 않기 때문이다.
그래서 위 Title부분은 그대로 나열하되 Description 부분은 겹치게 둔 것이다.
하지만 이렇게 구성했을 때 css 스타일링에서 문제가 있었다.
1. absolute 처리로 인해 Title요소의 css 부분과 일치하지 않는 문제
2. height 자체를 고정시켜서 기능하게 하면 가능하지만 일단 현재 설계는 Description의 내용 크기에 따라 height가 동적으로 변하게 했음
이렇게 말하면 어려우니 실제 개발 모습을 보자.
체크한 부분이 css 스타일링에서의 문제점이다.
위와 같은 문제로 애초에 설계할 때 다르게 한다면 가능할 것도 같았지만 일단 css 부분은 설계하지 않고 개발했기 때문에 넘어가도록 하겠다.
결론적으로 위처럼 DOM트리를 웹 접근성에 좋도록 기능을 만들 때 중요한 점은 css 스타일링이 어떻게 되어야하는지를 알아야하고, 설계할 때 정확히 해야할 것이다.
✨상태를 관리하지않고, <input> 태그를 이용해보자
이제 내부 상태를 관리하지 않고도 가능한 방법인 input태그 방법을 만들어보자.
아코디언 기능을 만들 때와 거의 유사하다.
이 기능은 DOM트리구조도 웹 접근성에 좋도록 설계할 수 있고, 내부 상태 관리가 필요없다.
//<input>과 <label>태그를 이용해서 내부 요소를 display속성으로 숨김/보임 처리
const TabItem = ({
id,
title,
description,
initialChecked
}: {
id: string
title: string
description: string
initialChecked: boolean
}) => {
return (
<Item key={id}>
<TabMenuInput
type="radio"
name={'tabmenu'}
id={id}
defaultChecked={initialChecked}
/>
<TabMenu5Title htmlFor={id}>{title}</TabMenu5Title>
<TabMenu5Description>{description}</TabMenu5Description>
</Item>
)
}
const TabMenu5 = () => {
return (
<>
<TabMenu5Container>
{data.map((d, i) => (
<TabItem
{...d}
key={d.id}
initialChecked={i === 0}
/>
))}
</TabMenu5Container>
</>
)
}
export default TabMenu5
input과 label태그를 이용할 때는 아래와 같이 css속성을 잘 지정해주면 된다.
//input 태그의 체크 유무에 따라 하위의 label 태그와 div 태그가 숨김/보임 처리가 되도록 했습니다.
export const TabMenuInput = styled.input`
display: none;
&:checked {
+ label {
background-color: #a5d6a7;
}
~ div {
display: block;
}
}
`
export const TabMenu5Title = styled.label`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
transition:
background-color 0.3s ease,
color 0.3s ease;
&:hover {
background-color: #c8e6c9;
color: #388e3c;
}
`
// 이전 방법과 마찬가지로 absolute 속성을 적용해야합니다.
// display속성을 이용한 숨김 보임 처리는 input태그의 css속성에서 처리합니다.
export const TabMenu5Description = styled.div`
position: absolute;
top: 100%;
left: 0;
width: 100%;
display: none;
//...
`
위 방법 또한 이전의 absolute 방식과 동일하기 때문에 css스타일 쪽으로 문제가 있다.
이 부분은 추후 다시 설계해야할 것 같다고 생각한다.
✨ctrl + F 기능을 추가해보자.
이전에 아코디언 기능에서 사용했던 ctrl + F를 이용해 요소를 찾으면 자동으로 탭을 열어주는 기능을 추가해보려고한다.이전 코드에서 간단하게 몇 개의 코드만 추가해주면 바로 가능하다.
이 ctrl + F 기능의 설명은 아코디언 기능 만들기 포스팅에서 확인할 수 있다.
const Description = ({
id,
current,
description,
toggle
}: {
id: string
current: boolean
description: string
toggle: () => void
}) => {
//beforematch이벤트를 등록하기 위한 ref생성
const descRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const node = descRef.current
if (node) {
node.addEventListener('beforematch', toggle)
}
return () => {
if (node) node.removeEventListener('beforematch', toggle)
}
}, [toggle])
//hidden 속성을 통해서 요소가 안보이도록
return (
<TabMenu6Description
key={id}
current={current}>
<div
className="description"
ref={descRef}
HIDDEN={current ? undefined : 'until-found'}>
{description}
</div>
</TabMenu6Description>
)
}
const TabMenu6 = () => {
const [currentId, setCurrentId] = useState<string>(data[0].id)
const toggleItem = (id: string) => () => {
setCurrentId(id)
}
return (
<>
<TabContainer>
<TabUl>
{data.map(d => (
<TabItem
{...d}
key={d.id}
current={currentId === d.id}
toggle={toggleItem(d.id)}
/>
))}
</TabUl>
{data.map(d => (
<Description
{...d}
key={d.id}
current={currentId === d.id}
toggle={toggleItem(d.id)}
/>
))}
</TabContainer>
)
}
간단하게 설명한 글이 있어서 가져왔다.
[hidden="until-found"] 는 HTML에 추가된 가장 최신 스펙 중 하나로, 숨겨진 영역의 콘텐츠를 검색하고 일치하는 내용이 있으면 beforematch 이벤트를 addEventListener() 로 받아 표시할 수 있다
추가로 나는 styled-component를 사용했는데 이를 사용할 때 주의할 점이 있다.
styled-component에는 hidden 속성이 적용되지 않는다는 점이다.
styled-component에 hidden속성을 지정하면 적용이 되지 않은 채로 DOM트리에 나타나게 된다.
그래서 div 태그를 따로 사용해주었다.
두 번째로 현재 기능이 있는 파일에 아래 주석을 사용했다.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
hidden 속성이 현재 리액트에는 반영이 안되어 있어서 HIDDEN으로 넣어주었다.
이유는 아래와 같다.
이론적으로 해결할 수 없는 문제에도 꼼수가 하나 존재한다. prop의 이름을 hidden을 HIDDEN으로. 대문자로 작성해 주입하는 방식이다.
이렇게 HIDDEN="until-found"를 사용하면 강제로 렌더링 결과에 내가 의도한 값을 넣어버릴 수 있다.
비슷하게 스타일을 넣을 때 Object로 넣어주어야 하는 style prop도 STYLE="margin:0; color: red;"와 같이 넣으면 실제 DOM에도 적용이 된다.
하지만, 이렇게 코드를 작성해놓으면 나중에 볼 때 기분도 안좋고 관리에도 불편함이 있다. 정말로 특별한 경우가 아니라면 해당 방법을 추천하지 않는다.
build에서 실패가 발생하지 않으려면 어쩔 수 없는 선택이었다.
아무튼 위 두가지 주의할 점을 유의하고 사용해야 한다.
📘마무리하며...
이렇게 탭 메뉴를 만들 수 있는 방법을 알아보았다.
기능을 만들면서 "합성 컴포넌트 패턴을 이용해서 만들 수는 없을까?" 라는 생각이 들었다.
일단 지금은 어떻게 TabMenu를 만드는가? 에 대해서 생각하고 진행한 것이기 때문에 추가하지는 않았지만
나중에 꼭 해보려고한다.
합성컴포넌트 패턴이 나중에 TabMenu를 모듈화했을 때 더 사용하기 편할 것이라고 생각된다.
'React' 카테고리의 다른 글
리액트 패턴 - Controlled and Uncontrolled Component Patterns(+ useForm 최적화하기) (0) | 2024.07.13 |
---|---|
리액트 패턴 - Disabled prop Pattern (0) | 2024.07.12 |
React - Eslint + Prettier 설정하기 (1) | 2024.06.11 |
리액트 패턴 - Render Props Pattern (1) | 2024.06.07 |
React - 아코디언 기능을 만들 때 어떻게 해야할까? (0) | 2024.05.31 |