Day 48. 리액트 컴포넌트 만들기(with styled-components)
Modal
모달은 기존에 이용하던 화면 위에 오버레이되는 창을 말한다.
닫기 버튼, 혹은 모달 범위 밖을 클릭하면 모달이 닫히는 것이 일반적이며, 모달을 닫기 전에는 기존 화면과 상호작용할 수 없다.
1. ModalBtn을 클릭하면 ModalBackdrop + ModalView가 보여야 한다.
1-1. isOpen으로 열리고 닫히는 상태를 관리한다.
1-2. openModalHandler라는 함수로 isOpen 의 boolean 값을 관리한다.
2. ModalView는 ModalBackdrop에 포함된 요소다.
3. ModalView의 x버튼을 누르거나, ModalBackdrop을 누르면 모달창이 꺼진다.
import { useState } from "react";
import styled from "styled-components";
export const ModalContainer = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
`;
export const ModalBackdrop = styled.div`
position: fixed;
display: flex;
justify-content: center;
align-items: center;
width: 800px;
height: 300px;
border-radius: 10px;
background-color: rgba(0,0,0,0.3);
`;
export const ModalBtn = styled.button`
background-color: var(--coz-purple-600);
text-decoration: none;
border: none;
padding: 20px;
margin-right: 50px;
color: white;
border-radius: 30px;
cursor: grab;
`;
export const ModalView = styled.div.attrs((props) => ({
role: "dialog",
}))`
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
width: 300px;
height: 100px;
background-color: white;
padding-bottom: 90px;
padding-top: 10px;
border-radius: 10px;
>button {
display: flex;
justify-content: center;
align-items: center;
background-color: white;
color: black;
border: 1px solid grey;
width: 30px;
height: 30px;
margin-bottom: 20px;
}
`;
export const Modal = () => {
const [isOpen, setIsOpen] = useState(false);
const openModalHandler = () => {
setIsOpen(!isOpen);
};
return (
<>
<ModalContainer onClick={isOpen ? openModalHandler : null}>
{isOpen ? (
<ModalBackdrop>
<ModalView onClick={(e) => e.stopPropagation()}>
<button onClick={isOpen ? openModalHandler : null}>x</button>
Hello World!
</ModalView>
</ModalBackdrop>
) : null}
<ModalBtn onClick={openModalHandler}>
{isOpen ? "Opened!" : "Open Modal"}
</ModalBtn>
</ModalContainer>
</>
);
};
Toggle
토글은 On/Off를 설정할 때 사용하는 스위치 버튼이다. 색상, 스위치의 위치, 그림자 등의 시각적 효과를 줘 사용자가 토글의 상태를 직관적으로 알 수 있게 만들어야 한다.
1. toggle-container, toggle-circle 두 개의 요소를 만든다.
2. isOn으로 on/off 를 관리한다.
3. toggleHandler로 isOn의 상태를 관리한다.
4. isOn인 경우, toggle-container, toggle-circle의 className에 toggle-checked라는 문자열을 추가해서 컨테이너의 색과 원의 위치를 변경한다. (애니메이션 추가)
import { useState } from "react";
import styled from "styled-components";
const ToggleContainer = styled.div`
position: relative;
margin-top: 8rem;
left: 47%;
cursor: pointer;
> .toggle-container {
border: 1px solid grey;
transition: 0.4s;
width: 50px;
height: 24px;
border-radius: 30px;
background-color: #8b8b8b;
&.toggle--checked {
background-color: #6666ff;
transition: 0.4s;
}
}
> .toggle-circle {
transition: 0.4s;
position: absolute;
top: 1px;
left: 1px;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: #ffffff;
&.toggle--checked {
transition: 0.4s;
left: 27px;
}
}
`;
const Desc = styled.div`
text-align: center;
padding: 20px;
`;
export const Toggle = () => {
const [isOn, setisOn] = useState(false);
const toggleHandler = () => {
setisOn(!isOn);
};
return (
<>
<ToggleContainer>
<div
onClick={toggleHandler}
className={isOn ? "toggle-container toggle--checked" : "toggle-container"}
/>
<div
onClick={toggleHandler}
className={isOn ? "toggle-circle toggle--checked" : "toggle-circle"}
/>
</ToggleContainer>
<Desc>{isOn ? "Toggle Switch On" : "Toggle Switch Off"}</Desc>
</>
);
};
Tab
탭은 콘텐츠를 분리해서 보여주고 싶을 때 사용하는 UI 디자인 패턴이다. 각 섹션의 구분이 명확해야 하며, 현재 어떤 섹션을 보고 있는지 표시해야 한다.
1. 섹션 개수에 따라 상태가 많아질 수 있기 때문에, 배열의 인덱스로 관리한다.
2. currentTab 으로 선택된 섹션을 표시한다.
3. menuArr이라는 배열을 만들어서 각 탭의 이름과 내용을 저장한다.
4. 컴포넌트 내에서 menuArr을 li로 하나씩 맵핑한다.
import { useState } from "react";
import styled from "styled-components";
const TabMenu = styled.ul`
background-color: lightgrey;
color: rgba(73, 73, 73, 0.5);
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
list-style: none;
/* padding: 10px 20px;
margin-bottom: 4rem;
margin-top: 4rem; */
.submenu {
padding: 10px;
width: calc(100% / 3);
display: flex;
justify-content: center;
cursor: pointer;
transition: 0.5s;
}
.focused {
background-color: pink;
}
& div.desc {
text-align: center;
}
`;
const Desc = styled.div`
text-align: center;
`;
export const Tab = () => {
const [currentTab, setCurrentTab] = useState(0);
const menuArr = [
{ name: "Tab1", content: "Tab menu ONE" },
{ name: "Tab2", content: "Tab menu TWO" },
{ name: "Tab3", content: "Tab menu THREE" },
];
const selectMenuHandler = (index) => {
setCurrentTab(index);
};
return (
<>
<div>
<TabMenu>
{menuArr.map((el, i) => {
return (
<li
key={i}
className={`${currentTab === i ? "submenu focused" : "submenu"}`}
onClick={()=>selectMenuHandler(i)} // 실행 형태로 넣으면 안됨. 이벤트에는 함수 형태만 넣어야하는데, 실행 형태를 넣으면 반환값이 들어가게 돼서 꼬인다. 콜백으로 넣어줘라
>
{menuArr[i].name}
</li>
);
})}
</TabMenu>
<Desc>
<p>{menuArr[currentTab].content}</p>
</Desc>
</div>
</>
);
};
** 주의해야 할 점
onClick 이벤트를 작성할 때, 함수의 실행 형태를 넣으면 안된다. 이벤트에는 함수 형태가 들어가야 하는데 실행 형태를 넣으면,
반환값으로 인식하기 때문에 원하는 대로 작동하지 않는다.
Tag
태그는 콘텐츠를 설명하는 키워드를 사용해서 라벨을 붙이는 역할을 한다. 사용자들은 자신이 작성한 콘텐츠에 태그를 붙임으로써 콘텐츠를 분류할 수 있고, 태그를 사용하여 관련 콘텐츠들만 검색할 수도 있다.
1. 초기값 배열을 만든다. initialTags = [];
2. tags라는 상태를 만들어서 추가와 삭제에 대한 변화를 관리한다.
3. 추가는 addTags, 삭제는 removeTags라는 함수로 동작한다.
3-1. addTags는 추가할 문자열을 감시하는 역할도 수행한다.
4. addTags는 input 요소에서 onKeyUp 이벤트다.
키가 입력되면 tag라는 변수에 문자열이 저장되고
Enter 키가 입력되면 지금까지 저장된 문자열을 tags 상태에 추가한다.
5. removeTags는 tag-close-icon의 onClick 이벤트다.
아이콘이 클릭되면 배열에서 해당 요소를 제거한다.
import { useState } from "react";
import styled from "styled-components";
export const TagsInput = styled.div`
margin: 8rem auto;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
min-height: 48px;
width: 480px;
padding: 0 8px;
border: 1px solid rgb(214, 216, 218);
border-radius: 6px;
> ul {
display: flex;
flex-wrap: wrap;
padding: 0;
margin: 8px 0 0 0;
> .tag {
width: auto;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
padding: 0 8px;
font-size: 14px;
list-style: none;
border-radius: 6px;
margin: 0 8px 8px 0;
background: var(--coz-purple-600);
> .tag-title {
color: #fff;
}
> .tag-close-icon {
display: block;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 14px;
margin-left: 8px;
color: var(--coz-purple-600);
border-radius: 50%;
background: #fff;
cursor: pointer;
}
}
}
> input {
flex: 1;
border: none;
height: 46px;
font-size: 14px;
padding: 4px 0 0 0;
:focus {
outline: transparent;
}
}
&:focus-within {
border: 1px solid var(--coz-purple-600);
}
`;
export const Tag = () => {
const initialTags = [];
const [tags, setTags] = useState(initialTags);
const removeTags = (indexToRemove) => {
const copy = [...tags];
copy.splice(indexToRemove, 1);
console.log(copy);
setTags(copy);
// setTags(tags.filter((el, index) => index !== indexToRemove)) 도 가능
};
const addTags = (e) => {
let tag = '';
tag += e.target.value;
if(e.key === 'Enter' && !initialTags.includes(tag) && e.target.value !== '') {
const copy = [tag];
setTags([...tags, ...copy]);
}
// e.target.value = '';
};
return (
<>
<TagsInput>
<ul id="tags">
{tags.map((tag, index) => (
<li key={index} className="tag">
<span className="tag-title">{tag}</span>
<span
className="tag-close-icon"
onClick={()=>{removeTags(index)}}
>
×
</span>
</li>
))}
</ul>
<input
className="tag-input"
type="text"
onKeyUp={(event) => { addTags(event) }}
placeholder="Press enter to add tags"
/>
</TagsInput>
</>
);
};
Auto complete
자동완성은 사용자가 내용을 입력 중일 때, 입력하고자 하는 내용과 일치할 가능성이 높은 항목을 보여주는 것이다.
너무 많은 항목이 나오지 않도록 개수를 제한하는 것이 좋으며, 키보드 방향키나 마우스 클릭 등으로 접근하여 사용할 수 있는 것이 좋다.
0. 배열에는 예시 단어들이 요소로 존재한다.
1. input에 값을 입력하면 드롭다운 메뉴가 나타나야 한다.
2. 내가 현재까지 입력한 문자열과 정확히 일치하는 요소들이 드롭다운 메뉴에 보이게 된다.
3. 드롭다운 메뉴에서 자동완성된 단어를 클릭하면 인풋 요소의 value는 해당 단어로 바뀐다.
4. x 버튼을 누르면 모든 글자가 지워진다. (초기화)
예를 들어 인풋 창에 'r'을 입력하면 드롭다운 메뉴에는 'rustic', 'refurbished'이 보이게 된다.
다음으로 'e'를 입력하면 'rustic'은 사라지고 'refurbished'만 보이게 된다.
드롭다운 메뉴에 자동완성 되어 있는 'refurbished'를 클릭하면 input 창의 value는
're'에서 'refurbished'로 바뀐다.
x 버튼을 누르면 input 창에 적혀 있던 글자는 모두 지워진다.
+ 드롭다운 메뉴에 자동완성 단어가 존재할 때, 키보드 방향키(위, 아래)로 원하는 단어를 선택해 검색어를 완성(엔터 키)할 수 있다.
1. hasText state로 input 값의 유무
를 확인할 수 있다.
-> 드롭다운 메뉴가 나타날지 말지 결정
-> onKeyUp 이벤트에서 방향키를 이용할 수 있을지 없을지 결정
-> 검색창의 아래쪽 테두리가 둥근 채로 있을지 각지게 변할지 결정
2. inputValue state로 input 값의 현재 상태
를 알 수 있다.
-> inputValue가 바뀔 때마다 useEffect가 실행된다. 빈 문자열이 될 때 모든 state를 초기화한다.
-> 검색어를 지속적으로 감시하면서 options(자동완성 조건)에 변화를 준다.
3. options state로 input 값을 포함하는 autocomplete 추천 항목 리스트
를 확인할 수 있다.
-> inputValue와 자기 자신이 갖고 있는 단어들을 비교해서 일치한다고 판단하는 것만 필터링한다.
import { useState, useEffect, useRef } from "react";
import styled from "styled-components";
const deselectedOptions = [
"rustic",
"antique",
"vinyl",
"vintage",
"refurbished",
"신품",
"빈티지",
"중고A급",
"중고B급",
"골동품",
];
const boxShadow = "0 4px 6px rgb(32 33 36 / 28%)";
const activeBorderRadius = "1rem 1rem 0 0";
const inactiveBorderRadius = "1rem 1rem 1rem 1rem";
export const InputContainer = styled.div`
margin-top: 8rem;
background-color: #ffffff;
display: flex;
flex-direction: row;
padding: 1rem;
border: 1px solid rgb(223, 225, 229);
border-radius: ${(props) =>
props.hasText ? activeBorderRadius : inactiveBorderRadius};
z-index: 3;
box-shadow: 0;
&:focus-within {
box-shadow: ${boxShadow};
}
> input {
flex: 1 0 0;
background-color: transparent;
border: none;
margin: 0;
padding: 0;
outline: none;
font-size: 16px;
}
> div.delete-button {
cursor: pointer;
}
`;
export const DropDownContainer = styled.ul`
background-color: #ffffff;
display: box;
margin-left: auto;
margin-right: auto;
list-style-type: none;
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 0px;
margin-top: -1px;
padding: 0.5rem 0;
border: 1px solid rgb(223, 225, 229);
border-radius: 0 0 1rem 1rem;
box-shadow: ${boxShadow};
z-index: 3;
> li {
padding: 0 1rem;
}
.focus {
background-color: blue;
color: white;
}
> li:hover {
background-color: blue;
color: white;
}
`;
export const Autocomplete = () => {
const [hasText, setHasText] = useState(false);
const [inputValue, setInputValue] = useState("");
const [options, setOptions] = useState(deselectedOptions);
const [idx, setIdx] = useState(-1);
useEffect(() => {
if (inputValue === "") {
setHasText(false);
setOptions([]);
setIdx(-1);
} else {
setOptions(
deselectedOptions.filter((el) => {
for (let i = 0; i < inputValue.length; i++) {
if (el[i] !== inputValue[i]) return false;
}
return true;
})
);
}
}, [inputValue]);
const handleInputChange = (event) => {
setHasText(true);
setInputValue(event.target.value);
// const copy = [...options];
// const filteredOp = copy.filter((el) => el.includes(event.target.value));
// setOptions(filteredOp);
// 여기서 setOptions를 해주는 것의 문제
// => 한 번 걸러지면 다시 안 돌아오니까 한글을 못 찾는 것.
// 'ㅅ'을 입력하면 배열의 모든 요소가 사라지는데 '신' 을 어떻게 찾을꼬..
// 그래서 useEffect로 inputValue가 바뀔 때마다 초기화해주는 것이 이 문제의 핵심
};
const handleDropDownClick = (clickedOption) => {
const autoCompleted = options[clickedOption];
setInputValue(autoCompleted);
};
const handleDeleteButtonClick = () => {
setInputValue("");
};
const handleKeyUp = (e) => {
if (hasText) {
if (e.key === "ArrowDown") {
if (idx < options.length - 1) setIdx((prev) => prev + 1);
console.log(idx, options[idx]);
} else if (e.key === "ArrowUp") {
if (idx >= 0) setIdx((prev) => prev - 1);
console.log(idx, options[idx]);
}
if (e.key === "Enter") {
setInputValue(options[idx]);
}
}
};
return (
<div className="autocomplete-wrapper">
<InputContainer hasText={hasText}>
<input
onChange={(e) => {
handleInputChange(e);
}}
onKeyUp={(e) => {
handleKeyUp(e);
}}
value={inputValue}
></input>
<div className="delete-button" onClick={handleDeleteButtonClick}>
×
</div>
</InputContainer>
{hasText ? (
<DropDown
options={options}
handleComboBox={handleDropDownClick}
idx={idx}
/>
) : null}
</div>
);
};
export const DropDown = ({ options, handleComboBox, idx }) => {
return (
<DropDownContainer>
{options.map((el, i) => {
return (
<li
key={i}
onClick={() => {
handleComboBox(i);
}}
className={idx === i ? "focus" : ""}
>
{el}
</li>
);
})}
</DropDownContainer>
);
};
** 처음에는 handleInputChange 내에서 setOptions의 과정까지 모두 수행했다. 하지만 그렇게 되면 한글을 입력할 경우 자동완성이 되지 않는다. 'ㅅ'을 입력하면 options 배열에 어떤 단어도 남지 않게 되어서, 그 이후에 '신'이라는 글자를 완성한다고 한들 '신품'이 자동완성에 뜰 수가 없다.
문제 해결 방법
- useEffect에 setOptions 과정을 넣어서 inputValue가 변할 때마다 다시 deselectedOptions를 필터링하면,
inputValue가 '신'이 되는 순간 '신품'을 정확히 찾아준다.
Click to edit
![]() |
![]() |
![]() |
1. newValue로 텍스트를 관리한다.
2. isEditMode로 newValue의 수정 가능 여부를 관리한다.
isEditMode가 true일 때는 MyInput 컴포넌트 내 InputBox 안의 요소가 InputEdit(텍스트 수정 가능)이고,
false일 때는 span(수정 불가능 텍스트)이 되도록 한다.
onBlur => 자기 자신을 제외한 부분을 클릭했을 때 실행되는 이벤트
span에는 onClick 이벤트를 넣고(클릭하면 isEditMode가 true가 되도록)
InputEdit에는 onBlur 이벤트를 넣는다.(클릭하면 isEditMode가 false가 되도록)
InputEdit에는 onChange 이벤트를 넣어서 newValue를 관리하고 newValue를 변경한 후에 외부를 클릭하면
onBlur 이벤트 함수 handleBlur가 실행되면서 newValue가 현재 입력되어 있는 텍스트로 변한다.
import { useEffect, useState, useRef } from 'react';
import styled from 'styled-components';
export const InputBox = styled.div`
text-align: center;
display: inline-block;
width: 150px;
height: 30px;
border: 1px #bbb dashed;
border-radius: 10px;
margin-left: 1rem;
`;
export const InputEdit = styled.input`
text-align: center;
display: inline-block;
width: 150px;
height: 30px;
`;
export const InputView = styled.div`
text-align: center;
align-items: center;
margin-top: 3rem;
div.view {
margin-top: 3rem;
}
`;
export const MyInput = ({ value, handleValueChange }) => {
const inputEl = useRef(null);
const [isEditMode, setEditMode] = useState(false);
const [newValue, setNewValue] = useState(value);
useEffect(() => {
if (isEditMode) {
inputEl.current.focus();
}
}, [isEditMode]);
useEffect(() => {
setNewValue(value);
}, [value]);
const handleClick = () => {
setEditMode(true);
};
const handleBlur = () => {
setEditMode(false);
handleValueChange(newValue);
};
const handleInputChange = (e) => {
setNewValue(e.target.value);
};
return (
<InputBox>
{isEditMode ? (
<InputEdit
type='text'
value={newValue}
ref={inputEl}
onBlur={handleBlur}
onChange={handleInputChange}
/>
) : (
<span
onClick={handleClick}
>{newValue}</span>
)}
</InputBox>
);
}
const cache = {
name: 'hajongon',
age: 100
};
export const ClickToEdit = () => {
const [name, setName] = useState(cache.name);
const [age, setAge] = useState(cache.age);
return (
<>
<InputView>
<label>이름</label>
<MyInput value={name} handleValueChange={(newValue) => setName(newValue)} />
</InputView>
<InputView>
<label>나이</label>
<MyInput value={age} handleValueChange={(newValue) => setAge(newValue)} />
</InputView>
<InputView>
<div className='view'>이름 {name} 나이 {age}</div>
</InputView>
</>
);
};
** 같은 newValue를 setName 함수에 입력하는 경우와 setAge 함수에 입력하는 경우로 나뉜다는 것에 주의하면 코드를 이해하는 데 큰 어려움은 없을 것이다.