원티드 X 코드스테이츠 프리온보딩 프론트엔드 과정 기업과제 3번
담당했던 부분 : Header / Selected Option / Footer 컴포넌트 작성
✨ 주요 기능
Available Options와 Selected Options로 나뉘어져있는 듀얼셀렉터입니다.
초기셋팅으로 데이터가 양쪽으로 나뉘어져있으며 사용자의 선택에 따라 Available이 될 수도 Selected가 될 수도 있습니다.
옵션 이동은 드래그 앤 드롭으로도 가능하고 셀렉터의 가운데 버튼메뉴로도 가능합니다.
버튼메뉴는 순서대로 초기화 | 전부 옮기기(>> / <<) | 선택된 항목 옮기기(> / <) 입니다.
아이템 선택은 Ctrl버튼, Shift버튼 모두 적용됩니다.
아이템 순서 변경도 드래그 앤 드롭으로 가능합니다.
소메뉴에서는 타이틀 및 아이템 폰트 사이즈, 가로세로 크기 변경이 가능하며 검색창, 선택된 아이템 갯수 창을 켜고 끌 수 있습니다.
소메뉴에서 하나씩만 옮기기 옵션을 ON 했을 시에는 Ctrl, Shift 버튼이 적용되지 않습니다.
검색기능은 각각의 옵션에서 적용됩니다.
사실 팀원들이 Next.js랑 Typescript를 사용하자고 해서 얼떨결에 같이 사용하고는 있지만 제대로 알고있지는 못하다. 그런데 이번에 프로젝트를 하던 중에 제대로 알지 못해서 쓸데없는 시간낭비를 하고 말았으니...
Next.js는 기본적으로 React로 만드는 서버사이드 렌더링 프레임워크이다. Typescript와 사용할 때 편리한 이점이 있어 이 두 개를 같이 사용한다고 한다. 어쨌든 이랬는데, 헤더를 만들면서 로그프레소 svg를 가져와야 하는 일이 생겼다. 그런데 react에서 이미지를 가져오는 모든 방법을 실행해봐도 로드되지 않아서 ...? 하고 있었는데 팀원분께서 next image를 깔아야 한다고 말해주셔서 바로... 해결이 되었다...
검색을 아무리 해도 잘못된 키워드를 넣어서 검색을 하고 앉아있었으니 해결방법이 나올리가 없었다... 어쨌든 이렇게 또 새로운 것을 배웠다...
이번 프로젝트에서는 전체적인 디자인 및 배치를 담당해서 flex를 좀 더 구조적으로 이해하면서 사용할 수 있게 되었다. 프론트엔드에서는 UI나 UX를 위해 확실히 정말 세세한 부분까지 신경써야 한다는 것을 배우고 있다. 조금이라도 중앙으로 컴포넌트를 정렬하기 위해서 모든 코드들을 하나하나 뜯어보고 이해하려고 애쓰는 것을 팀원들과 함께 많이 하고 있다. 그리고 이 컴포넌트가 이게 나을지 저게 나을지, 또는 이 크기가 맞는지 등등의 대해서 의견을 나누는 것도 하면서 사람마다 각자 보는 눈은 다르기는 하지만 전체적으로 으레 되어야 하는 기능이 있고 그 전체인 기준은 위반할 수 없다는 것도 배우고 있다.
이번에 프로젝트 하면서도 기업이 준 문서에는 기능구현 부분에 크게 설명이 없어서 금방 끝날 거라고 생각했는데, Ctrl과 Shift 키의 기능 활성화 부분에서 너무 여러가지의 경우의 수가 있었고 그것들을 구현하는 것이 정말 정말 힘들다는 것을 깨달았다. 컴퓨터를 쓰면서 의식조차 하지 못 하고 있던 "당연히 이정도는 되어야지"라고 했던 부분들이었는데, 이것을 직접 구현하는 것은 또 전혀 다른 차원의 일이라는 것을 깨달았다.
여담이지만 Ctrl+Z의 기능도 구현하기가 매우 힘들다고 한다. 이거 없으면 살아갈 수 없는데...
// Header
import styled from 'styled-components'
import Image from 'next/image'
import logo from '../public/logo-default.svg'
import Setting from 'components/Setting'
const Header = () => {
return (
<ImageWrapper>
<Image alt="logo" src={logo} width={'1000px'} height={'100px'} />
<Setting />
</ImageWrapper>
)
}
const ImageWrapper = styled.div`
text-align: center;
padding: 2rem 0 0 0;
margin-bottom: 2rem;
position: relative;
`
export default Header
// Footer
import styled from 'styled-components'
interface IFooter {
total: number
selectedCount: number
}
const Footer = ({ total, selectedCount }: IFooter) => {
return (
<>
<SelectedListNum>
{selectedCount} / {total}
</SelectedListNum>
</>
)
}
const SelectedListNum = styled.div`
font-size: 2.5rem;
text-align: center;
padding: 1.5rem;
box-shadow: rgba(70, 53, 53, 0.25) 0px 0.0625em 0.0625em,
rgba(0, 0, 0, 0.25) 0px 0.125em 0.5em,
rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset;
border-radius: 1rem;
margin-bottom: 2rem;
`
export default Footer
// Options
import styled from 'styled-components'
import SearchBar from './SearchBar'
import Footer from './Footer'
import { Draggable, Droppable } from 'react-beautiful-dnd'
import { useAppSelector } from 'redux/store'
import React, { useEffect, useState } from 'react'
import { selectSelector } from 'redux/slice'
interface IOptions {
type: 'available' | 'selected'
checkedId: number[]
onChangeCheckedId: (arr: number[]) => void
}
interface CssProps {
id: any
selectedId: any
}
interface IStartEnd {
start: number | null
end: number | null
direction: 'up' | 'down' | null
}
const Options = ({ type, checkedId, onChangeCheckedId }: IOptions) => {
const { option } = useAppSelector(selectSelector)
const dataList = useAppSelector((state) => state.selector.items[type])
const [query, setQuery] = useState('')
const filterId = dataList.filter((item) => checkedId.includes(item.id))
const selectedId = filterId.map((item) => item.id)
const [startEnd, setStartEnd] = useState<IStartEnd>()
useEffect(() => {
if (option.onlyOne) {
onChangeCheckedId([])
}
}, [option.onlyOne, onChangeCheckedId])
// shift, ctrl, 단일 클릭 로직
const onClick = (
e: React.MouseEvent<HTMLDivElement>,
id: number,
index: number
) => {
if (option.onlyOne) {
e.shiftKey = false
e.ctrlKey = false
e.metaKey = false
}
if (e.shiftKey) {
let newCheckId: number[]
// 처음 눌렀을 때
if (startEnd?.start === null || startEnd?.start === undefined) {
newCheckId = dataList.slice(0, index + 1).map((data) => data.id)
onChangeCheckedId(newCheckId)
setStartEnd({
start: 0,
end: index,
direction: null,
})
} else {
// 시프트로 영역 지정 후 안에 클릭시
if (checkedId.includes(id)) {
if (startEnd.direction && startEnd.end !== null) {
if (startEnd.direction === 'up') {
newCheckId = dataList
.slice(index, startEnd.end + 1)
.map((data) => data.id)
const removeCheckId = dataList
.slice(startEnd.start, startEnd.end + 1)
.map((data) => data.id)
onChangeCheckedId([
...checkedId.filter(
(id: number) => !removeCheckId.includes(id)
),
...newCheckId,
])
} else {
newCheckId = dataList
.slice(startEnd.start, index + 1)
.map((data) => data.id)
const removeCheckId = dataList
.slice(startEnd.start, startEnd.end + 1)
.map((data) => data.id)
onChangeCheckedId([
...checkedId.filter(
(id: number) => !removeCheckId.includes(id)
),
...newCheckId,
])
}
}
} else if (index < startEnd.start) {
newCheckId = dataList
.slice(index, startEnd.start + 1)
.map((data) => data.id)
if (startEnd.end !== null) {
const shiftedCheckId = dataList
.slice(startEnd.start, startEnd.end + 1)
.map((data) => data.id)
onChangeCheckedId([
...checkedId.filter((id) => !shiftedCheckId.includes(id)),
...newCheckId,
])
} else {
onChangeCheckedId([
...checkedId,
...newCheckId.slice(0, newCheckId.length - 1),
])
}
setStartEnd({
start: index,
end: startEnd.start,
direction: 'up',
})
} else {
if (startEnd.end !== null) {
newCheckId = dataList
.slice(startEnd.end, index + 1)
.map((data) => data.id)
const shiftedCheckId = dataList
.slice(startEnd.start, startEnd.end + 1)
.map((data) => data.id)
onChangeCheckedId([
...checkedId.filter((id) => !shiftedCheckId.includes(id)),
...newCheckId,
])
setStartEnd({
start: startEnd.end,
end: index,
direction: 'down',
})
} else {
newCheckId = dataList
.slice(startEnd.start + 1, index + 1)
.map((data) => data.id)
onChangeCheckedId([...checkedId, ...newCheckId])
setStartEnd({
...startEnd,
end: index,
direction: 'down',
})
}
}
}
} else {
setStartEnd({
start: index,
end: null,
direction: null,
})
if (e.ctrlKey || e.metaKey) {
if (checkedId.includes(id)) {
onChangeCheckedId(checkedId.filter((v) => v !== id))
} else {
onChangeCheckedId([...checkedId, id])
}
} else {
if (checkedId.length === 1 && checkedId[0] === id) {
onChangeCheckedId([])
} else {
onChangeCheckedId([id])
}
}
}
}
const showList = () => {
if (query.length > 0) {
const filtered = dataList.filter((el) =>
el.name.toLowerCase().includes(query)
)
return filtered.map((el) => {
return (
<SingleList key={el.id} id={el.id} selectedId={selectedId}>
<ListContent className={`${option.itemSize}`}>
<span>{el.emoji}</span>
<span>{el.name}</span>
</ListContent>
</SingleList>
)
})
} else {
return dataList.map((el, index) => {
return (
<Draggable key={el.id} draggableId={String(el.id)} index={index}>
{(provided) => (
<SingleList
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onClick={(e) => onClick(e, el.id, index)}
id={el.id}
selectedId={selectedId}
>
<ListContent className={`${option.itemSize}`}>
<span>{el.emoji}</span>
<span>{el.name}</span>
</ListContent>
</SingleList>
)}
</Draggable>
)
})
}
}
return (
<ListWrapper>
{option.search && <SearchBar setQuery={setQuery} />}
<AvailableWrapper width={option.width} height={option.height}>
<Availabletype>
{type === 'available' ? option.titles[0] : option.titles[1]}
</Availabletype>
<Droppable droppableId={type}>
{(provided) => (
<ListBox {...provided.droppableProps} ref={provided.innerRef}>
{showList()}
{provided.placeholder}
</ListBox>
)}
</Droppable>
</AvailableWrapper>
{option.selectedItems && (
<Footer total={dataList.length} selectedCount={selectedId.length} />
)}
</ListWrapper>
)
}
const ListWrapper = styled.div``
const AvailableWrapper = styled.div<{ width: number; height: number }>`
box-shadow: rgba(70, 53, 53, 0.25) 0px 0.0625em 0.0625em,
rgba(0, 0, 0, 0.25) 0px 0.125em 0.5em,
rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset;
border-radius: 15px;
z-index: 1000;
min-width: 25rem;
width: ${({ width }) => `${width}px`};
height: ${({ height }) => `${height}px`};
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: 2rem;
`
const Availabletype = styled.div`
font-size: 3.5rem;
font-weight: 700;
font-family: 'Ubuntu', sans-serif;
padding: 1.5rem 3.4rem;
border-bottom: 1px solid lightgray;
`
const ListBox = styled.div`
min-height: 100%;
overflow: auto;
`
const SingleList = styled.div<CssProps>`
display: flex;
flex-direction: row;
font-size: 2.5rem;
border-bottom: 1px solid lightgray;
/* margin-top: 0.1rem; */
display: flex;
text-decoration: none;
color: inherit;
position: relative;
padding: 2.5rem 0 2rem 3.5rem;
background-color: #fff;
cursor: pointer;
&:before {
position: absolute;
left: 0;
bottom: 0;
content: '';
display: block;
width: 100%;
height: 100%;
background: rgb(255, 201, 71);
background: linear-gradient(
90deg,
rgba(255, 201, 71, 1) 0%,
rgba(255, 125, 74, 1) 50%,
rgba(254, 69, 77, 1) 100%
);
transform-origin: 0 bottom 0;
transform: scaleY(0);
transition: 0.2s ease-out;
${({ selectedId, id }) => {
// ctrl + click
return selectedId.includes(id) ? 'transform: scaleY(1)' : ''
}};
}
&:hover {
background-color: #eee;
}
div {
position: relative;
font-size: 2rem;
font-weight: 700;
line-height: 1.333;
transition: 0.2s ease-out;
}
`
const ListContent = styled.div`
& span:first-child {
margin-right: 1.5rem;
}
&.XS span {
font-size: 1.5rem;
}
&.S span {
font-size: 2rem;
}
&.M span {
font-size: 2.5rem;
}
`
export default Options