CSS-in-JS !== Inline Styles
styled-component를 작성하기 위해서 TTL로 작성하다 보면 종종 inline style로 작성하는 것처럼 느껴질 때가 있습니다. 그러나 만약 inline style과 같다면 CSS-in-JS를 대신하지 않았을까요? inline style에서 할 수 없는 부분들에 대해서 CSS-in-JS가 더 많은 기능을 제공합니다. inline-style과 styled-component는 서로 비슷해 보입니다.
// styled-component
export const StyledButton: React.FC<Props> = ({ content, handleClick, margin }) => {
return (
<Button onClick={() => handleClick()} margin={margin}>
{content}
</Button>
);
};
const getMargin = (margin?: string) => {
switch (margin) {
case 'margin':
return `
margin: 15px;
`;
case 'margin-top':
return `
margin-top: 15px;
`;
case 'margin-bottom':
return `
margin-bottom: 15px;
`;
case 'margin-left':
return `
margin-left: 15px;
`;
case 'margin-right':
return `
margin-right: 15px;
`;
default:
return `
margin: 0;
`;
}
};
const Button = styled.button<{ margin?: string }>`
flex: 1;
width: 100%;
border-radius: 3px;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.2px;
height: 40px;
padding: 0px 16px;
border: 0;
cursor: pointer;
${props => getMargin(props.margin)};
`;
// inline-styles
export const InlineButton: React.FC<Props> = ({ content, handleClick, margin }) => {
return (
<button onClick={() => handleClick()} style={styles(margin)}>
{content}
</button>
);
};
const styles = (marginStyle: string) => {
return {
flex: '1',
width: '100%',
borderRadius: '3px',
fontSize: '14px',
fontWeight: '500',
height: '40px',
padding: '0px 16px',
cursor: 'pointer',
margin: marginStyle === 'margin' ? '15px' :
marginStyle === 'margin-top' ? '15px, 0, 0, 0' :
marginStyle === 'margin-bottom' ? '0, 0, 15px, 0' :
marginStyle === 'margin-left' ? '0, 0, 0, 15px' :
marginStyle === 'margin-right' ? '0, 15px, 0, 0' :
'0',
};
};
다음과 같이 버튼 컴포넌트를 만드는 것에 대해서 비슷한 작성 방식을 보입니다. 그러나 styled-component는 사실은 <style> tag를 DOM에 inject를 하는 방식으로 작동합니다. 이는 큰 차이를 보입니다. DOM node의 속성에 대해서 styles를 string으로 전달하는 inline style이 할 수 없는 (pseudo class, media query, keyframe, ...)을 CSS를 전달하기 때문에 가능하게 합니다. useMediaQuery, web animation API (https://bitsofco.de/css-animations-vs-the-web-animations-api) 등이 존재해서 해당 간극을 좁히고 있지만, 아무래도 이런 추가적 처리보다는 JSS를 사용하는 것이 좋겠다는 생각입니다.
Storybook
디자인 시스템은 재사용이 가능한 UI Components로 이루어지며 복잡하고 견고하지만 사용자가 접근하기에 용이한 사용자 인터페이스를 구축하도록 도와줍니다. 레고 블록들을 합쳐서 문과 창문을 만들고, 이들을 통해서 집 또는 성, 상황에 따른 필요한 것들을 만드는 것을 생각해 보시면 이해가 더 잘 되실 것 같습니다. 그런데 이러한 디자인 시스템에 대해서 구성을 할 때는 디자이너와 개발자가 협업을 하게 됩니다. 그렇기에 디자이너와 개발자 모두 만족시키는 구조를 구성해야 합니다.
🏗 재사용이 가능한 공용 UI 컴포넌트
🎨 디자인 토큰: 브랜드 색상, 간격과 같은 스타일 변수
📕 문서: 사용 방법, 설명, 좋은 예와 나쁜 예
해당 요소에 대한 구성을 storybook은 제공하고 있습니다. (https://storybook.js.org/tutorials)
UI 컴포넌트를 별개로 작성하지만 문서를 자동으로 생성해 디자이너, 개발자 모두에게 도움을 줍니다.
storybook 가이드라인에 따라서 설치를 쉽게 할 수 있습니다. (https://storybook.js.org/docs/riot/get-started/install)
// Add Storybook:
npx sb init
다만 webpack버전이 맞지 않을 경우 build-storybook에서 오류가 발생할 수 있습니다. 해당 해결법은 (storybook-webpack5-experimental.md)을 통해서 해결 가능합니다. 저의 경우 해당 방식에서 살짝 다른 부분은 .storybook/main.js파일의 core부분이었습니다. 버전 간 호환성이 조속히 해결되면 좋겠습니다.
// this works for me
// this can be wrong on your dependency or environment settings
module.exports = {
"stories": [
"../src/**/*.stories.mdx",
"../src/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials"
],
"core": {
"builder": "webpack5",
}
}
Styled-Component with Storybook
styled-component로 작성된 컴포넌트를 storybook를 통해서 관리하는 방식에 대해서 예시를 통해 소개드리겠습니다.
1. CustomButtons
// CustomButton.tsx
import React from 'react';
import styled from 'styled-components';
type Props = {
content: string;
handleClick: Function;
customStyle?: string;
margin?: string;
};
export const CustomButton: React.FC<Props> = ({ content, handleClick, customStyle, margin }) => {
return (
<Button onClick={() => handleClick()} customStyle={customStyle} margin={margin}>
{content}
</Button>
);
};
const getCustomStyle = (customStyle?: string) => {
switch (customStyle) {
case 'action':
return `
color: #ffffff;
background-color: #ff5600;
&:hover {
background-color: #f63105;
}
&:focus {
border:1px solid black;
}
&:disabled {
color: ##ffb894;
background-color: #fff2e9;
}
`;
case 'cancel':
return `
color: gray;
background-color: rgb(232, 232, 232);
&:hover {
background-color: rgb(212, 212, 212);
}
&:focus {
border:1px solid black;
}
&:disabled {
color: black;
background-color: rgba(232, 232, 232, 0.5);
}
`;
default:
return `
color: white;
background-color: black;
&:focus {
border:1px solid white;
}
`;
}
};
const getMargin = (margin?: string) => {
switch (margin) {
case 'margin':
return `
margin: 20px;
`;
case 'margin-top':
return `
margin-top: 20px;
`;
case 'margin-bottom':
return `
margin-bottom: 20px;
`;
case 'margin-left':
return `
margin-left: 20px;
`;
case 'margin-right':
return `
margin-right: 20px;
`;
default:
return `
margin: 0;
`;
}
};
const Button = styled.button<{ customStyle?: string; margin?: string }>`
flex: 1;
width: 100%;
border-radius: 3px;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.2px;
height: 40px;
padding: 0px 16px;
border: 0;
cursor: pointer;
${props => getCustomStyle(props.customStyle)};
${props => getMargin(props.margin)};
`;
지금 와서 드는 생각으로는, button의 내용에 string을 넣는 방식보다는 type을 React.ReactNode나, 아니면 render props pattern, children(https://reactjs.org/docs/react-api.html#reactchildren)을 사용해서 동적으로 내용을 넣는 것이 좋았을 것 같다는 생각이 들었습니다. 또한 margin option에 대해서 다르게 작성하는 것이 좋았겠다는 아쉬움이 남네요. storybook을 작성할 때, storiesOf를 사용하는 것보다는, export default와 각 항목에 대해서 export const MyStory를 사용해서 작성하는 것이 좋습니다. propTypes를 사용할 때 생기는 에러(https://github.com/tuchk4/storybook-readme/issues/177)를 해결하기 때문입니다. 아직 issue open인 상태인 것으로 보이니 해결 전까지는 export default를 통해서 storybook을 세팅하는 것이 좋겠습니다.
addon-actions(https://github.com/storybookjs/storybook/tree/master/addons/actions)를 사용하는 것으로, function에 대해서 mocking과 실행 결과를 tracking 할 수 있게 도와줍니다.
// CustomButton.stories.tsx
import React from 'react';
import { action } from '@storybook/addon-actions';
import { CustomButton } from './CustomButton';
export default {
title: 'CustomButton',
component: CustomButton,
};
export const Action = (args: any) => <CustomButton {...args} />;
Action.args = {
content: 'CONFIRM_BUTTON',
handleClick: () => {
action('action');
},
customStyle: 'action',
};
export const Cancel = (args: any) => <CustomButton {...args} />;
Cancel.args = {
content: 'CANCEL_BUTTON',
handleClick: () => {
action('cancel');
},
customStyle: 'cancel',
};
export const Normal = (args: any) => <CustomButton {...args} />;
Normal.args = {
content: 'NORMAL_BUTTON',
handleClick: () => {
action('normal');
},
};
export const Margin = (args: any) => <CustomButton {...args} />;
Margin.args = {
content: 'MARGIN: 20px',
handleClick: () => {
action('margin');
},
};
export const MarginTop = (args: any) => <CustomButton {...args} />;
MarginTop.args = {
content: 'MARGIN_TOP: 20px',
handleClick: () => {
action('marginTop');
},
};
export const MarginBottom = (args: any) => <CustomButton {...args} />;
MarginBottom.args = {
content: 'MARGIN_BOTTOM: 20px',
handleClick: () => {
action('marginBottom');
},
};
export const MarginLeft = (args: any) => <CustomButton {...args} />;
MarginLeft.args = {
content: 'MARGIN_LEFT: 20px',
handleClick: () => {
action('marginLeft');
},
};
export const MarginRight = (args: any) => <CustomButton {...args} />;
MarginRight.args = {
content: 'MARGIN_RIGHT: 20px',
handleClick: () => {
action('marginRight');
},
};
2. Modal
import React, { useEffect } from 'react';
import styled from 'styled-components';
import closeButton from '../../assets/close.svg';
type Props = {
handleClose: Function;
renderElement: {
headerElement: React.ReactNode;
bodyElement: React.ReactNode;
footerElement: React.ReactNode;
};
size?: string;
};
export const Modal: React.FC<Props> = ({ handleClose, renderElement, size }) => {
useEffect(() => {
document.body.style.cssText = `position: fixed; top: -${window.scrollY}px; width: 100%;`;
return () => {
const scrollY = document.body.style.top;
document.body.style.cssText = 'position: ""; top: "";';
window.scrollTo(0, parseInt(scrollY || '0', 10) * -1);
};
}, []);
return (
<>
<Dimmer onClick={() => handleClose()} />
<Container size={size}>
<Header>
{renderElement.headerElement}
<CloseButton role="button" onClick={() => handleClose()}>
<img src={closeButton} alt="close" />
</CloseButton>
</Header>
<Body>{renderElement.bodyElement}</Body>
<Footer>{renderElement.footerElement}</Footer>
</Container>
</>
);
};
const Dimmer = styled.div`
position: fixed;
width: 100%;
height: 100%;
left: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
`;
const getSize = (size?: string) => {
switch (size) {
case 'small':
return `
width: 360px;
height: 240px;
left: calc(50% - 180px);
bottom: calc(50% - 120px);
`;
case 'big':
return `
width: 600px;
height: 480px;
left: calc(50% - 300px);
bottom: calc(50% - 240px);
`;
default:
return `
width: 480px;
height: 360px;
left: calc(50% - 240px);
bottom: calc(50% - 180px);
`;
}
};
const Container = styled.div<{ size?: string }>`
display: flex;
box-sizing: border-box;
padding: 32px;
flex-direction: column;
position: fixed;
background-color: #ffffff;
width: 100%;
height: 280px;
left: 0;
bottom: 0;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
@media (min-width: 1024px) {
${props => getSize(props.size)};
border-radius: 8px;
}
`;
const Header = styled.div`
display: flex;
margin-top: 10px;
padding-bottom: 30px;
overflow: hidden;
`;
const CloseButton = styled.div`
position: absolute;
width: 24px;
height: 24px;
right: 32px;
cursor: pointer;
`;
const Body = styled.div`
display: flex;
padding-bottom: 10px;
max-height: 120px;
@media (min-width: 1024px) {
max-height: 172px;
}
`;
const Footer = styled.div`
display: flex;
`;
Modal의 경우 storybook을 작성할 때 추가적으로 설정해 주어야 하는 부분이 있습니다. storybook은 설정에 따라서 mode를 가질 수 있습니다. 기본적으로 가지고 있는 Canvas 이외에도 Docs, Notes, GraphiQL 등 추가를 할 수 있습니다. storybook의 예시(https://storybooks-official.netlify.app)를 참고하시면 되겠습니다. 스토리들, 텍스트 설명, 그리고 docgen comments 등을 하나로 합쳐서 보여주는 부분을 DocsPage(https://storybook.js.org/docs/riot/writing-docs/docs-page)라고 하는데, 해당 부분에 있어서 컴포넌트가 position: fixed / absolute로 작성이 되어 있으면 Canvas mode와 다르게 작성이 될 수 있습니다. 작성을 하고자 하는 부분에 대해서 iframe처리를 하는 것을 통해서 ViewPort를 삽입하고 관리할 수 있습니다. @storybook/addon-docs에 사용 방법도 있으니 한번 확인하시면 되겠습니다.
// @storybook/addon-docs/common/README.md
...
## IFrame height
In the "common" setup, Storybook Docs renders stories inside `iframe`s, with a default height of `60px`. You can update this default globally, or modify the `iframe` height locally per story in `DocsPage` and `MDX`.
To update the global default, modify `.storybook/preview.js`:
```ts
import { addParameters } from '@storybook/ember';
addParameters({ docs: { iframeHeight: 400 } });
```
For `DocsPage`, you need to update the parameter locally in a story:
```ts
export const basic = () => ...
basic.parameters = {
docs: { iframeHeight: 400 }
}
```
And for `MDX` you can modify it, especially if you work with some components using fixed or sticky positions, as an attribute on the `Story` element:
```md
<Story name='basic' height='400px'>{...}</Story>
```
...
// Modal.stories.tsx
import React from 'react';
import { action } from '@storybook/addon-actions';
import { Modal } from './Modal';
export default {
title: 'Modal',
component: Modal,
parameters: {
docs: {
inlineStories: false,
iframeHeight: 450,
},
},
};
export const SmallSize = (args: any) => <Modal {...args} />;
SmallSize.args = {
handleClose: () => {
action('close');
},
renderElement: {
headerElement: <>SmallSize</>,
bodyElement: <>360px / 240px</>,
footerElement: <>width < 1024 ? 'BottomSheet' : 'modal' </>,
},
size: 'small',
};
SmallSize.parameters = {
docs: {
iframeHeight: 300,
},
};
export const NormalSize = (args: any) => <Modal {...args} />;
NormalSize.args = {
handleClose: () => {
action('close');
},
renderElement: {
headerElement: <>NormalSize</>,
bodyElement: <>480px / 360px</>,
footerElement: <>width < 1024 ? 'BottomSheet' : 'modal' </>,
},
};
export const BigSize = (args: any) => <Modal {...args} />;
BigSize.args = {
handleClose: () => {
action('close');
},
renderElement: {
headerElement: <>BigSize</>,
bodyElement: <>600px / 480px</>,
footerElement: <>width < 1024 ? 'BottomSheet' : 'modal' </>,
},
size: 'big',
};
BigSize.parameters = {
docs: {
iframeHeight: 600,
},
};
docs.inlineStories: false option을 적용하는 것을 통해서 iframe을 사용하도록 도와줍니다. 해당 부분은 default: true이며 그 이유는 제가 짐작(?)하기로는 iframe이 필요 없는 상황에서 굳이 해당 부분을 삽입해서 브라우저의 사용성(ex, 뒤로 가기 버튼 / 해상도 / ... )에 대해서 문제를 만들지 않고자 한 것이 아닐까 합니다. iframeHeight로 story의 height를 설정하지만, width는 불가능한 것(https://github.com/storybookjs/storybook/issues/8816)을 식별했습니다. 그래서 모달이 ViewPort에 따라서 bottom sheet style을 적용하도록 해 두었는데 이 부분을 Docs mode에서는(언제나 100%를 가져서) 적용할 수 없었습니다. 각 행의 크기를 조절하는 것이 전체적인 스타일을 망칠 수 있기 때문인 듯합니다. 또한 Canvas mode에서 media query를 변경하는 것에 있어서 default setting의 경우 1024 -> 768로 할 때 height가 768 -> 1024로 바뀌는 것을 확인했는데 이 부분도 차후 수정이 되어야 하지 않을까 싶습니다.
3. Toast
// Toast.tsx
import React, { useContext, useEffect } from 'react';
import styled, { keyframes, css } from 'styled-components';
import { CommonActionType } from '../../reducer/actions';
import { Context } from '../../App';
const DELAY = 2000;
type Props = unknown;
export const Toast: React.FC<Props> = () => {
const { state, dispatch } = useContext(Context);
useEffect(() => {
setTimeout(() => {
dispatch({
type: CommonActionType.SHOW_TOAST,
toast: { show: false, message: '' },
});
}, DELAY);
}, [dispatch]);
return (
<>
{state.common.toast && (
<Snackbar>
<span>{state.common.toast.message}</span>
</Snackbar>
)}
</>
);
};
const fadein = keyframes`
0% { bottom: 0px; opacity: 0; }
100% { bottom: 30px; opacity: 1; }
`;
const fadeout = keyframes`
0% { bottom: 30px; opacity: 1; }
100% { bottom: 0px; opacity: 0; }
`;
const Snackbar = styled.div`
display: flex;
align-items: center;
flex-direction: column;
position: fixed;
width: 75%;
margin: 0 auto;
left: 0;
right: 0;
height: 20px;
background-color: black;
color: white;
border-radius: 10px;
padding: 1rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-size: 0.8rem;
line-height: 0.2;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
-webkit-animation: ${css`
${fadein} 0.5s, ${fadeout} 0.5s 1.5s
`};
animation: ${css`
${fadein} 0.5s, ${fadeout} 0.5s 1.5s
`};
animation-fill-mode: forwards;
`;
Context API의 store를 사용하고 있다면, storybook의 작성도 이에 맞춰서 수정되는 부분들이 있습니다. store에 대해서 mocking이 필요하고, 해당 부분을 provider로 설정하는 작업이 필요합니다. 아쉬운 점으로, 해당 소스를 이러한 방식보다는 컴포넌트 내부에서는 store를 사용하지 않으면서 독립적인 코드로 구성을 하고 props로 store.common.toast를 전달하는 것이 리액트 및 디자인 시스템의 추구하는 방향에 부합할 듯합니다. 리팩토링은 마쳤으나 storybook에서 Context API를 사용하는 방법을 소개하기 위해서 해당 소스 공유드립니다.
// Toast.stories.tsx
import React from 'react';
import { action } from '@storybook/addon-actions';
import { State } from 'reducer/states';
import { Toast } from './Toast';
import { Context } from '../../App';
const store = {
state: {
common: {
toast: {
show: true,
message: '토스트 메시지',
},
},
} as State,
dispatch: action('dispatch'),
};
const withStore = (node: React.ReactNode) => <Context.Provider value={store}>{node}</Context.Provider>;
export default {
title: 'Toast',
component: Toast,
parameters: {
docs: {
inlineStories: false,
iframeHeight: 100,
},
},
};
export const Toast = (args: any) => withStore(<Toast {...args} />);
다 작성하고 나서 아쉬운 것으로, storybook의 관리를 위해서 Modal은 entry point(진입 버튼)을 만들어 주고 Toast는 activation point(실행 버튼)을 만드는 방식을 선택하는 것이 더 좋겠다는 생각이 들었습니다. 해당 수정이 맘에 들게 작성이 될 경우 소스도 같이 업데이트를 하도록 하겠습니다.
※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.
※ storybook의 design system을 구성할 때 많이 적용하시는 atomic design에 대해서 소개드리겠습니다.
'React > CSS' 카테고리의 다른 글
CSS #5 - CSS-in-JS (1) Pros and Cons (2) | 2021.06.20 |
---|---|
CSS #4 - SASS (3) with CSS Module (0) | 2021.05.23 |
CSS #3 - SASS (2) with CSS Module (0) | 2021.05.01 |
CSS #2 - SASS (1) (0) | 2021.04.19 |
CSS #1 - CSS priority (1) | 2021.04.18 |