React-custom-component 과제: React, Styled-Component, Storybook을 활용해 UI 컴포넌트 개발
import { useState } from "react";
import styled from "styled-components";
export const ModalContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
position: relative;
`;
export const ModalBackdrop = styled.div`
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1;
`;
export const ModalBtn = styled.button`
background-color: var(--coz-purple-600);
text-decoration: none;
border: 4px solid var(--coz-purple-600);
padding: 20px;
color: white;
border-radius: 30px;
font-size: 20px;
cursor: pointer;
&:hover{
background-color: white;
color: var(--coz-purple-600);
}
`;
export const ModalView = styled.div.attrs((props) => ({
// attrs 메소드를 이용해서 아래와 같이 div 엘리먼트에 속성을 추가할 수 있습니다.
role: "dialog",
}))`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: white;
width: 400px;
height: 200px;
border-radius: 30px;
position: relative;
> .desc {
font-size: 30px;
color: #475ed4;
margin: 50px;
justify-content: center;
align-items: center;
}
> .close {
padding: 5px 10px;
}
`;
export const Modal = () => {
const [isOpen, setIsOpen] = useState(false);
const openModalHandler = (e) => {
setIsOpen(!isOpen);
};
return (
<>
<ModalContainer>
{/* 클릭하면 isOpen 상태 변경 함수 실행 */}
<ModalBtn onClick={openModalHandler}>
{/* Modal이 열린 상태(isOpen이 true인 상태)일 때는 'Opened!' Modal이 닫힌 상태(isOpen이 false인 상태)일 때는 'Open Modal' */}
{isOpen ? "Opened!" : "Open Modal"}
</ModalBtn>
{isOpen ? (
// 모달 창 밖을 클릭하면 Modal배경, Modal창 div 엘리먼트가 사라지게 하기
// 부모 컴포넌트에 이벤트 핸들러가 걸려있을 때 자식 컴포넌트에도 같은 핸들러가 작동이 되는데,
// 이때 자식 컴포넌트에서는 작동을 안하게 해주려면 그 해당 이벤트 핸들러에 stopPropagation()를 활용해주면 된다.
<ModalBackdrop onClick={openModalHandler}>
<ModalView onClick={e => e.stopPropagation()}>
<div className="desc">Good!</div>
<ModalBtn className="close" onClick={openModalHandler}>Close</ModalBtn>
</ModalView>
</ModalBackdrop>
) : null}
</ModalContainer>
</>
);
};
import { useState } from 'react';
import styled from 'styled-components';
// styled components에서 '>' 는 자식 선택자, '&' 는 자기 자신을 참조
const ToggleContainer = styled.div`
position: relative;
margin-top: 8rem;
left: 47%;
cursor: pointer;
> .toggle-container {
width: 50px;
height: 24px;
border-radius: 30px;
background-color: #8b8b8b;
&.toggle--checked {
background-color: pink;
}
}
> .toggle-circle {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #ffffff;
transition: .5s;
&.toggle--checked {
left: 28px;
transition: .5s;
}
}
`;
const Desc = styled.div`
text-align: center;
margin: 20px;
`;
export const Toggle = () => {
const [isOn, setisOn] = useState(false);
const toggleHandler = () => {
setisOn(!isOn);
};
return (
<>
{/* 클릭하면 isOn 상태 변경 함수 실행 */}
<ToggleContainer onClick={toggleHandler}>
{/* isOn 상태에 따라 클래스명 추가하여 토글이 움직이게 */}
<div className={`toggle-container ${isOn && 'toggle--checked'}`}/>
<div className={`toggle-circle ${isOn && 'toggle--checked'}`}/>
</ToggleContainer>
{/* 삼항연산자로 textContent 조건부 렌더링 */}
<Desc>Toggle Switch {isOn ? 'ON' : 'OFF'}</Desc>
</>
);
};
import { useState } from "react";
import styled from "styled-components";
const TabMenu = styled.ul`
font-weight: bold;
display: flex;
flex-direction: row;
justify-items: center;
justify-content: center;
align-items: center;
list-style: none;
margin-top: 2rem;
margin-bottom: 7rem;
> li:hover {
background-color: #686868;
color: white;
transition: 1s;
}
.submenu {
background-color: #dcdcdc;
color: rgba(73, 73, 73, 0.5);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
width: 100px;
height: 30px;
margin: 0 5px;
cursor: pointer;
}
.focused {
background-color: #686868;
color: white;
}
& div.desc {
text-align: center;
}
`;
const Desc = styled.div`
text-align: center;
font-size: 20px;
font-weight: 700;
`;
export const Tab = () => {
const menuArr = [
{ name: "Tab1", content: "Tab menu ONE" },
{ name: "Tab2", content: "Tab menu TWO" },
{ name: "Tab3", content: "Tab menu THREE" },
];
const [currentTab, setCurrentTab] = useState(0);
const selectMenuHandler = (idx) => {
setCurrentTab(idx); // 인덱스를 전달받아 currentTab 상태 업데이트
};
return (
<>
<div>
<TabMenu>
{/* map을 사용하여 menuArr 배열 내 모든 요소를 li 엘리먼트로 렌더링 */}
{menuArr.map((tab, idx) => {
return (
<li
key={idx}
onClick={() => selectMenuHandler(idx)}
className={currentTab === idx ? "submenu focused" : "submenu"}
>
{tab.name}
</li>
);
})}
</TabMenu>
<Desc>
{/* 현재 선택된 탭에 따라 content를 표시 */}
<p className='desc'>{menuArr[currentTab].content}</p>
</Desc>
</div>
</>
);
};
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; // app.css 에서 전체 선택자가 적용되어 있음 -> 특정 요소의 속성을 변경하고 싶다면, 해당 요소를 정확히 선택해야만 적용됨
}
> .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 = ['CodeStates', 'kimcoding'];
const [tags, setTags] = useState(initialTags);
// 태그 삭제
const removeTags = (indexToRemove) => {
const filter = tags.filter((el, idx) => idx !== indexToRemove);
setTags(filter);
};
// 태그 추가
const addTags = (e) => {
// 이미 입력되어 있는 태그인지 검사하여 이미 있는 태그라면 추가하지 말기
// 아무것도 입력하지 않은 채 Enter 키 입력시 메소드 실행하지 말기
if (e.key === 'Enter' && e.target.value !== '' && !tags.includes(e.target.value)) {
setTags([...tags, e.target.value]);
e.target.value = ''; // 태그가 추가되면 input 창 비우기
}
};
return (
<>
<TagsInput>
<ul id="tags">
{tags.map((tag, index) => (
<li key={index} className="tag">
<span className="tag-title">{tag}</span>
{/* 삭제 아이콘을 클릭하면 removeTags 함수 실행 */}
<span className="tag-close-icon" onClick={() => removeTags(index)}>
{/* HTML 엔티티로 X 아이콘 넣기 */}
×
</span>
</li>
))}
</ul>
{/* input 창에서 엔터 키 누르면 addTags 함수 실행 */}
<input
className="tag-input"
type="text"
onKeyUp={(e) => {addTags(e)}}
placeholder="Press enter to add tags"
/>
</TagsInput>
</>
);
};
import { useState, useEffect } 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: ${inactiveBorderRadius};
z-index: 3;
box-shadow: 0;
&:focus-within {
border-radius: ${activeBorderRadius};
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: block;
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;
&.selected {
background-color: lightgray;
}
&:hover {
background-color: lightgray;
}
}
`;
export const Autocomplete = () => {
const [hasText, setHasText] = useState(false); // input값의 유무
const [inputValue, setInputValue] = useState(""); // input값의 상태
const [options, setOptions] = useState(deselectedOptions); // input값을 포함하는 autocomplete 추천 항목 리스트
const [selectedIdx, setSelectedIdx] = useState(-1); // 키보드로 option 선택할때 필요한 selected 상태. 처음엔 아무것도 선택되지 않도록 인덱스 -1로 초기값 설정
useEffect(() => {
setSelectedIdx(-1); // 방향키로 선택한 옵션 초기화
if (inputValue === "") {
//처음 렌더링 됐을 때의 상태와, input값을 모두 지워줬을 때
setHasText(false); // input값의 유무 상태를 false로 변경
setOptions([]); // option은 빈 배열로 만들어 아래에 리스트가 나오지 않도록
}
if (inputValue !== "") {
// input값에 무언가 입력하면
setOptions(
deselectedOptions.filter((option) => option.includes(inputValue))
); // 입력된 값을 포함하는 option만 필터링
}
}, [inputValue]);
const handleInputChange = (e) => {
setInputValue(e.target.value); // inputValue를 입력된 값으로 변경
setHasText(true); // input값 유무 상태를 true로 변경
};
const handleDropDownClick = (clickedOption) => {
setInputValue(clickedOption); // 전달받은 option으로 input값 변경
};
const handleDeleteButtonClick = (e) => {
setInputValue(""); // x 버튼을 누르면 input값을 비워준다
};
/* Advanced Challenge: 상하 화살표 키 입력 시 dropdown 항목을 선택하고,
Enter 키 입력 시 input값을 선택된 dropdown 항목의 값으로 변경 */
const handleKeyUp = (e) => {
if (hasText) {
// (e.nativeEvent.isComposing === false) : 맨 처음 ArrowDown 키 두번 인식되는 문제 해결
if (e.key === "ArrowDown" && selectedIdx < options.length - 1 && e.nativeEvent.isComposing === false) {
setSelectedIdx(selectedIdx + 1);
} else if (e.key === "ArrowUp" && selectedIdx >= 0) {
setSelectedIdx(selectedIdx - 1);
}
// 엔터 키로 옵션 선택 => handleDropDownClick()로 전달해 input값 변경
if (e.key === "Enter" && selectedIdx >= 0) {
handleDropDownClick(options[selectedIdx]);
}
}
};
return (
<div className="autocomplete-wrapper">
<InputContainer>
{/* value를 state와 연결, handleInputChange 함수와 handleKeyUp 함수 각각 연결 */}
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyUp={handleKeyUp}
></input>
{/* */}
<div className="delete-button" onClick={handleDeleteButtonClick}>
×
</div>
</InputContainer>
{/* input 값 유무에 따라 dropdown 보이게 조건부 렌더링 */}
{hasText ? (
<DropDown
options={options}
handleComboBox={handleDropDownClick}
selectedIdx={selectedIdx}
/>
) : null}
</div>
);
};
export const DropDown = ({ options, handleComboBox, selectedIdx }) => {
return (
<DropDownContainer>
{/* input 값에 맞는 autocomplete 선택 옵션이 보여지게 */}
{/* 현재 선택된 항목에 클래스명 넣어주어 스타일 적용 */}
{options.map((option, idx) => {
return (
<li
key={idx}
onClick={() => handleComboBox(option)}
className={selectedIdx === idx ? "selected" : ""}
>
{option}
</li>
);
})}
</DropDownContainer>
);
};
import { useEffect, useState, useRef } from 'react';
import styled from 'styled-components';
export const InputViewContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
`
export const InputBox = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 150px;
height: 30px;
border: 1px #bbb dashed;
border-radius: 10px;
margin-left: 1rem;
`;
export const InputEdit = styled.input`
display: inline-block;
justify-content: center;
align-items: center;
text-align: center;
width: 150px;
height: 30px;
`;
export const InputView = styled.div`
display: flex;
justify-content: center;
text-align: center;
align-items: center;
margin: 1rem;
div.view {
margin-top: 3rem;
}
`;
export const MyInput = ({ value, handleValueChange }) => {
const inputEl = useRef(null); // 특정 요소(input)에 포커싱 해주기 위해 useRef 사용하여 주소값 가져오기
const [isEditMode, setEditMode] = useState(false);
const [newValue, setNewValue] = useState(value);
useEffect(() => {
if (isEditMode) {
inputEl.current.focus();
}
}, [isEditMode]);
useEffect(() => {
setNewValue(value);
}, [value]);
const handleClick = () => {
// span 요소 클릭 이벤트 발생 시 EditMode 상태를 true로 변경 => 위의 useEffect 실행 => input창에 포커싱
setEditMode(true);
};
const handleBlur = () => {
handleValueChange(newValue); // input 창에 입력되어있는 값으로 newValue를 바꿔준다.
setEditMode(false); // input 창 외의 곳을 클릭 시(포커스를 잃으면) EditMode 상태를 false로 변경
};
const handleInputChange = (e) => {
setNewValue(e.target.value); // newValue의 상태를 input에 입력한 값으로 변경
};
return (
<InputBox>
{isEditMode ? (
<InputEdit
type='text'
value={newValue}
ref={inputEl}
onBlur={handleBlur}
onChange={handleInputChange}
/>
) : (
<span onClick={handleClick}>{newValue}</span>
)}
</InputBox>
);
}
const cache = {
name: '김코딩',
age: 20
};
export const ClickToEdit = () => {
const [name, setName] = useState(cache.name);
const [age, setAge] = useState(cache.age);
return (
<>
<InputViewContainer>
<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>
</InputViewContainer>
</>
);
};