퀴즈앱을 만들었는데, 문제가 잘 안보이는 이슈...
결국에는 디자인을 할 필요성이 있었음.
<div style={{ whiteSpace: "pre" }}>
{[
slicedTextArray[0],
...isCorrect.map((boolean, idx) => {
if (boolean) return slicedTextArray[idx + 1];
else return replacedTextArray[idx];
}),
].join("")}
</div>
문자열을 하나의 태그로 묶는 방식에서,
(innerHTML 방식을 피하려면)
텍스트를 분리하여, 각 각의 텍스트를 태그로 묶어 스타일링 해야함.
그리고.... 그럴줄 알고 \{ \}
로 문제를 묶어놨었다.
중괄호를 기준으로 문자열을 나누고,
각 각의 문자열을 서로 다른 태그로 감싸면 스타일링이 된다.
<div style={{ whiteSpace: "pre" }}>
<span>{slicedTextArray[0]}</span>
{isCorrect.map((boolean, idx) => {
if (boolean) return <span>{slicedTextArray[idx + 1]}</span>;
else {
const [left, rest] = replacedTextArray[idx].split("{");
const [center, right] = rest.split("}");
return (
<>
<span>{left}</span>
<StyledQuiz>{center}</StyledQuiz>
<span>{right}</span>
</>
);
}
})}
</div>
어떤 문자열 { n번 문제 } 저런 문자열
은 중괄호를 기준으로 나누면 결국엔
[어떤 문자열, [n번 문제, 저런 문자열]]
로 2개의 뎁스에 총 3개의 요소가 있도록 나뉘게 된다.
이를 구조분해 할당으로 받았고,
'n번 문제' 스타일드 컴포넌트를 씌웠다.
스타일이 잘 씌워진다.
스토리북
스토리북 get started with next js
next js는 웹팩을 수정 가능하기에 웹팩에 스토리북을 포함하여 빌드해주면 된다. 추가적으로 next/image, next/route 등의 핵심 기능이 스토리북과 호환되도록 설정해주어야 한다.
그리고 최신 버전의 스토리북 7과 next.js 12에 대해서는 아래와 같이 무설정 설치를 지원한다.
최신) storybook@next
npx storybook@next init
위 명령어를 실행하면 바벨에 설정을 추가할지 등을 묻는 안내문이 뜨며, 일반적으로 yes를 눌러주면 된다.
yarn storybook
으로 실행하면 된다.
(프리셋을 추가할지에 대해 Y를 선택했다면 기본 프리셋이 들어있을 것이다.)
스토리북 구조
루트디렉토리/stories <- 스토리북이 들어있는 폴더이다.
Button.tsx <- 버튼 컴포넌트이다. props에 따라 디자인이 달라진다.
//Button.tsx
interface ButtonProps {
primary?: boolean;
backgroundColor?: string;
size?: 'small' | 'medium' | 'large';
label: string;
onClick?: () => void;
}
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ?
'storybook-button--primary'
: 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button',
`storybook-button--${size}`,
mode]
.join(' ')}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};
Button.stories.ts는 해당 스토리에 props를 정의한다.
정의된 props에 따라 디자인이 달라지는 것을 확인할 수 있다
//Button.stories.ts
export const Primary: Story = {
args: {
primary: true,
label: 'Button',
},
};
export const Secondary: Story = {
args: {
label: 'Button',
},
};
export const Large: Story = {
args: {
size: 'large',
label: 'Button',
},
};
export const Small: Story = {
args: {
size: 'small',
label: 'Button',
},
};
버튼 추가하기
Button.tsx를 componets/common/atom/Button.tsx로 이동시킨다.
props에 색상을 추가해주고
interface ButtonProps {
color?:
| "primary"
| "secondary"
| "orange"
| "pupple"
| "teal"
| "red"
| "green"
| "blue";
backgroundColor?: string;
size?: "small" | "medium" | "large";
disabled?: boolean;
label: string;
onClick?: () => void;
}
Styled컴포넌트로 감싸준다.
Styled-component에서 class별로 스타일하는 방법은 두가지가 있는데,
- 글로벌 스타일에 해당하는 스타일을 쿼리 셀렉터와 추가해주는 방법
- 기존 태그를 styled.span으로 감싸는 방법.
여기서는 두 번째 방법을 사용했다.
const mode = disabled ? "storybook-button--disabled" : "";
return (
<StyledButton>
<button
type="button"
className={[
"storybook-button",
`storybook-button--${size}`,
`storybook-button--${color}`,
mode,
].join(" ")}
style={{ backgroundColor }}
disabled={disabled}
{...props}
>
{label}
</button>
</StyledButton>
);
export const StyledButton = styled.span`
...
.storybook-button--blue {
color: rgba(64, 103, 249, 1);
background-color: rgba(64, 103, 249, 0.05);
box-shadow: rgba(64, 103, 249, 1) 0px 0px 0px 1px inset;
}
.storybook-button--disabled {
opacity: 0.6;
cursor: default;
}
색상 조합은 https://codepen.io/aybukeceylan/details/OJRNbZp의 색상들을 참조했다.
storied/Button.stories.ts 에 예시로 볼 스토리를 추가하고 yarn storybook
으로 실행하면 된다.
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "../components/common/atom/Button";
...
export const Secondary: Story = {
args: {
color: "secondary",
label: "Button",
},
};
export const Disabled: Story = {
args: {
color: "secondary",
label: "Button",
disabled: true,
},
};
트러블 슈팅
ReferenceError: React is not defined 에러가 발생하였다.
스토리북을 설치하는 과정에서 바벨 설정이 바뀌면서 발생한 에러로 확인되었다.
[err] ReferenceError: React is not defined @Noob.log
기존
{
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": 100
}
}
],
"@babel/preset-typescript",
"@babel/preset-react",
],
"plugins": []
}
변경 후
{
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": 100
}
}
],
"@babel/preset-typescript",
["@babel/preset-react", { "runtime": "automatic" }]
],
"plugins": []
}
키워드에 버튼 스타일 씌우기
스타일을 만들었으니, 앞서 만든 키워드들의 버튼마다 props를 넘겨 스타일을 씌워야 한다.
props를 담는 객체를 선언했다.
const [keywordStyleArray, setKeywordStyleArray] = useState(
[] as Array<ButtonProps>
);
이후 키워드 배열을 map으로 순회하며 스타일을 담는 배열을 만들어 적용했다.
useEffect안에 들어있는 내용이 아래와 같이 수정되었다.
const sortedAllKeywords = allAnswersKeyword.sort(() => Math.random() - 0.5);
const sortedAllKeywordStyle = sortedAllKeywords.map(
(element, index) =>
({
label: element,
color: "blue",
disabled: false,
} as ButtonProps)
);
setAllAnswersKeyword(sortedAllKeywords);
setKeywordStyleArray(sortedAllKeywordStyle);
이를 이렇게 저장된 props를 컴포넌트에 넘겨주면 된다.
props를 넘겨줄 때에는 단순하게 spread 연산자를 사용하면 된다.
이때
<컴포넌트 {...props} prop1='prop1>
과 같이 스레드 연산자가 앞에 오도록 하고, 뒤에 세부적으로 정의할 props들을 넣으면 뒤쪽에 있는 props들로 overriding 된다.
<div>
{allAnswersKeyword.map((keyword, idx) => {
const styleProps = keywordStyleArray[idx];
return (
<Button
{...styleProps}
label={keyword}
value={keyword}
onClick={onClickKeyword}
/>
);
})}
</div>
버튼 상호작용 만들기
이렇게 추가로 props 객체와 배열을 만든 이유는 정답일 때, 혹은 오답일 때 스타일을 변경하기 위함이다.
바닐라 자바스크립트에서는 e.target의 클래스 배열에 클래스를 push, pop하면서 다루면 되지만, 리액트에서는 props 객체를 조작하는 것이 권장된다.
const onClickKeyword = (e) => {
const idx = keywordStyleArray.findIndex(
(element) => element.label === e.target.value
);
const newKeywordStyleArray: Array<ButtonProps> = [...keywordStyleArray];
if (e.target.value === answersKeyword[answerIndex]) {
const newIsCorrect = [...isCorrect];
newIsCorrect[answerIndex] = true;
e.target.disabled = true;
setIsCorrect(newIsCorrect);
newKeywordStyleArray[idx].disabled = true;
newKeywordStyleArray[idx].color = "green";
newKeywordStyleArray.forEach((element) => {
if (element.color === "green") return;
element.color = "blue";
element.disabled = false;
});
} else {
newKeywordStyleArray[idx].disabled = true;
newKeywordStyleArray[idx].color = "red";
}
setKeywordStyleArray(newKeywordStyleArray);
};
위 로직은 간단한데,
기존 배열의 내용을 바탕으로 새로운 배열을 만들고,
새로운 배열의 객체들의 props를 변경한 뒤,
(문제가 틀렸을 경우, 해당하는 문제의 색깔을 빨간색으로 바꾸고 disabled 시키고,
문제가 맞았을 경우, 해당하는 문제의 색깔을 초록색으로 바꾸고 disabled 시키고, 초록색이 아닌 버튼들은 다시 abled 상태로 바꾼다.)
setKeywordStyleArray로 배열을 갈아끼워주면 된다.
배열 내부의 객체들은 깊은 복사 상태라서 메모리주소가 바뀌지 않지만, 'keywordStyleArray.map()'이 작동하면서 리렌더링 되므로 상관없다.
한편 위와 같은 방식은 버튼을 하나만 눌러도 나머지 10개의 버튼이 모두 리렌더링 된다는 문제점이 있는데,
각각의 버튼을 새로운 컴포넌트로 만들면 해결이 가능하다.
현재는 버튼의 갯수가 10개로 정해져있어 시간복잡도가 O(1)이므로 최적화 하지 않고 진행하기로 한다.
버튼 컨테이너 만들기
components\common\molecules\QuizAnswerContainer.tsx에 버튼들을 flex-box로 보여주는 컨테이너를 만들었다.
그리고 여기에 가운데 버튼을 추가하여 높이가 변경되도록, FAB(Floating Action Button) 같은 사용자 경험을 추가해보았다.
import React, { useState } from "react";
import styled from "styled-components";
import { Button } from "../atoms/Button";
const QuizAnswerContainer = ({ ...props }) => {
const [activated, setActivated] = useState(false);
return (
<StyledQuizAnswers>
<div className="actionButton">
<Button
onClick={() => {
setActivated(!activated);
}}
label={activated ? "숨기기" : "+"}
size="small"
color="orange"
backgroundColor="white"
></Button>
</div>
<div
className="buttons"
style={
activated
? { height: "30vh", opacity: "1" }
: { height: "0vh", opacity: "0" }
}
>
{props.children}
</div>
</StyledQuizAnswers>
);
};
export default QuizAnswerContainer;
export const StyledQuizAnswers = styled.div`
position: fixed;
bottom: 0;
width: 100%;
max-height: 30%;
text-align: center;
-webkit-box-shadow: 0 -8px 10px -6px rgba(255, 148, 46, 0.3);
-moz-box-shadow: 0 -8px 10px -6px rgba(255, 148, 46, 0.3);
box-shadow: 0 -8px 10px -6px rgba(255, 148, 46, 0.3);
background: white;
.buttons {
overflow: hidden;
justify-content: space-around;
transition: all 200ms;
}
.actionButton {
position: relative;
top: -17px;
}
`;
이제 모바일에서도 보이도록 text에서 스크롤을 할 수 있게 해주고 (스크롤이 편하도록 buttonWrapper 크기만큼 padding을 주었다.)
//components\common\molecules\TextWrapper.tsx
import React from "react";
import styled from "styled-components";
const TextWrapper = ({ children }) => {
return (
<StyledTextWrapper>
{children}
<div className="bottom-padding"></div>
</StyledTextWrapper>
);
};
export default TextWrapper;
const StyledTextWrapper = styled.div`
font-family: "Noto Sans KR";
height: 100%;
width: 100%;
overflow-y: scroll;
white-space: pre-wrap;
line-height: 1.5rem;
padding: 1rem;
.bottom-padding {
width: 100%;
height: 40vh;
}
`;
적당히 반응형이 되도록 잘 섞어주면 된다.
//components\common\organisms\QuizContainerWrapper.tsx
import React from "react";
import styled from "styled-components";
export const QuizContainerWrapper = ({ children }) => {
return (
<StyledQuizContainer>
<div>{children}</div>
</StyledQuizContainer>
);
};
export const StyledQuizContainer = styled.div`
height: 100%;
width: 100%;
@media screen and (min-width: 769px) {
position: absolute;
height: 50%;
width: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 1px solid black;
border-radius: 0.5rem;
}
`;
모바일 화면
반응형 화면
적절하게 잘 작동하고 있다.