💻 Text/index.js
import PropTypes from 'prop-types';
const Text = ({
children,
block,
paragraph,
size,
strong,
underline,
delete: del,
color,
mark,
code,
...props
}) => {
const Tag = block ? 'div' : paragraph ? 'p' : 'span';
const fontStyle = {
fontWeight: strong ? 'bold' : undefined,
fontSize: size,
textDecoration: underline ? 'underline' : undefined,
color,
};
// 취소선은 이미 예약어가 존재하여 del로 따로 지정한다.
// 여기부터 태그를 이용하여 수정하는 방식을 사용한다.
if(del) {
children = <del>{children}</del>
}
if(mark) {
children = <mark>{children}</mark>
}
if(code) {
children = <code>{children}</code>
}
return (
<Tag style={{...props.style, ...fontStyle}} {...props}>
{children}
</Tag>
)
}
Text.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.number,
block: PropTypes.bool,
paragraph: PropTypes.bool,
delete: PropTypes.bool,
code: PropTypes.bool,
mark: PropTypes.bool,
strong: PropTypes.bool,
color: PropTypes.string,
}
export default Text;
💻 Text.stories.js
import Text from "../components/Text";
export default {
title: 'Components/Text',
component: Text,
argTypes: {
size: {control: 'number'},
strong: {control: 'boolean'},
underline: {control: 'boolean'},
delete: {control: 'boolean'},
color: {control: 'color'},
block: {control: 'boolean'},
paragraph: {control: 'boolean'},
mark: {control: 'boolean'},
code: {control: 'boolean'},
}
};
export const Default = (args) => {
return (
<>
<Text {...args}>Text</Text>
<Text {...args}>Text</Text>
</>
);
};
🖨 완성본
💻 Header/index.js
import PropTypes from 'prop-types';
const Header = ({ children, level =1, strong, underline, color, ...props }) => {
let Tag = `h${level}`;
if(level < 1 || level > 6) {
console.warn('Header only accept 1 | 2 | 3 | 4 | 5 | 6 as `level` value.');
Tag = "h1";
}
const fontStyle = {
fontWeight: strong ? 'bold' : 'normal',
textDecoration: underline ? 'underline' : undefined,
color,
};
return <Tag style={{...props.style, ...fontStyle}} {...props}>{children}</Tag>;
};
Header.propTypes = {
children: PropTypes.node.isRequired,
level: PropTypes.number,
underline: PropTypes.bool,
strong: PropTypes.bool,
color: PropTypes.string,
}
export default Header;
💻 Header.stories.js
import Header from "../components/Header";
export default {
title: 'Components/Header',
component: Header,
argTypes: {
level: { control: {type: "range", min: 1, max: 6}},
strong: { control: 'boolean'},
underline: { control: 'boolean'},
color: { control: 'color'},
}
};
export const Default = (args) => {
return (
<>
<Header {...args}>Header</Header>
</>
);
};
🖨 완성본
💻 Image/index.js
import PropTypes from 'prop-types';
import { useState, useRef, useEffect } from 'react';
// 모듈 내에서 전역적으로 사용하기 위해 컴포넌트 외부에 선언
let observer = null;
const LOAD_IMG_EVENT_TYPE = 'loadImage';
const onIntersection = (entries, io) => {
entries.forEach(entry => {
if(entry.isIntersecting) {
io.unobserve(entry.target);
entry.target.dispatchEvent(new CustomEvent(LOAD_IMG_EVENT_TYPE));
}
})
}
const Image = ({
lazy,
threshold = 0.5,
placeholder,
src,
block,
width,
height,
alt,
mode,
...props
}) => {
const [loaded, setLoaded] = useState(false);
const imgRef = useRef(null)
const imageStyle = {
display: block ? 'block' : undefined,
width,
height,
objectFit: mode // cover, fill, contain
};
useEffect(() => {
if(!lazy) {
setLoaded(true);
return;
}
const handleLoadImage = () => setLoaded(true);
const imgElement = imgRef.current;
imgElement && imgElement.addEventListener(LOAD_IMG_EVENT_TYPE, handleLoadImage);
return () => {
imgElement && imgElement.removeEventListener(LOAD_IMG_EVENT_TYPE, handleLoadImage);
}
}, [lazy]);
useEffect(() => {
if(!lazy) return;
observer = new IntersectionObserver(onIntersection, { threshold })
imgRef.current && observer.observe(imgRef.current);
}, [lazy, threshold])
return <img ref={imgRef} src={loaded ? src : placeholder} alt={alt} style={{ ...props.style, ...imageStyle }} />;
};
Image.propTypes = {
lazy: PropTypes.bool,
threshold: PropTypes.number,
src: PropTypes.string.isRequired,
placeholder: PropTypes.string,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
alt: PropTypes.string,
mode: PropTypes.string
}
export default Image;
💻 Image.stories.js
import Image from "../components/Image";
export default {
title: 'Component/Image',
component: Image,
argTypes: {
lazy: {
defaultValue: false,
control: {type: 'boolean'}
},
block: {
defaultValue: false,
control: {type: 'boolean'}
},
src: {
name: 'src', // name은 생략해도 상관없다.
type: {name: 'string', require: true},
defaultValue: 'https://picsum.photos/200',
control: {type : 'text'}
},
placeholder: {
type: {name: 'string'},
defaultValue: 'https://via.placeholder.com/200',
control: {type : 'text'}
},
threshold: {
type: { name: 'number'},
defaultValue: 0.5,
control: { type: 'number'}
},
width: {
name: 'width',
defaultValue: 200,
control: {type:'range', min: 200, max: 600}
},
height: {
name: 'height',
defaultValue: 200,
control: {type:'range', min: 200, max: 600}
},
alt: {
name: 'alt',
control: 'string'
},
mode: {
defaultValue: 'cover',
options: ['cover', 'fill', 'contain'],
control: { type: "inline-radio"},
},
}
};
export const Default = (args) => {
return (<>
<Image {...args}/>
<Image {...args}/>
</>)
};
export const Lazy = (args) => {
return (
<div>
{
Array.from(new Array(20), (_, k) => k).map((i) => (
<Image lazy block {...args} src={`${args.src}?${i}`} key={i} />
))
}
</div>
);
};
🖨 완성본
💻 Spacer/index.js
import React from "react";
const Spacer = ({ children, type = 'horizontal', size = 8, ...props }) => {
const spacerStyle = {
...props.style,
display: type === 'vertical' ? 'block' : 'inline-block',
verticalAlign: type === 'horizontal' ? 'middle' : undefined
};
const nodes = React.Children.toArray(children)
.filter(element => React.isValidElement(element))
.map((element, index, elements) => {
return React.cloneElement(element ,{
...element.props,
style: {
...element.props.style,
marginRight: type === 'horizontal' && index !== elements.length -1 ? size : undefined,
marginBottom: type === 'vertical' && index !== elements.length -1 ? size : undefined
}
})
})
return (
<div {...props} style={spacerStyle}>
{nodes}
</div>
);
};
export default Spacer;
💻 Spacer.stories.js
import Spacer from "../components/Spacer"
export default {
title: 'Component/Spacer',
component: Spacer,
argTypes: {
size: {
defaultValue: 8,
control: { type: 'range', min: 8, max: 64}
}
}
};
const Box = ({block, style}) => {
return (
<div style = {{
display: block ? 'block' : 'inline-block',
width: 100,
height: 100,
backgroundColor: "blue",
...style,
}}></div>
);
};
export const Horizontal = (args) => {
return (
<Spacer {...args} type="horizontal">
<Box />
<Box />
<Box />
</Spacer>
)
}
export const vertical = (args) => {
return (
<Spacer {...args} type="vertical">
<Box block/>
<Box block/>
<Box block/>
</Spacer>
)
}
🖨 완성본
💻 Spinner/index.js
import styled from '@emotion/styled'
const Icon = styled.i`
display: inline-block;
vertical-align: middle;
`
const Spinner = ({ size = 24, color='#919EAB', loading = true, ...props }) => {
const sizeStyle = {
width: size,
height: size,
}
return (
loading ? <Icon>
<svg viewBox="0 0 38 38" xmlns="http:www.w3.org/2000/svg" style={sizeStyle}>
<g fill='none' fillRule='evenodd'>
<g transform='translate(1 1)'>
<path d='M36 18c0-9.94-8.06-18-18-18' stroke={color} strokeWidth='2'>
<animateTransform attributeName='transform' type='rotate' from='0 18 18' to='360 18 18' dur='0.9s' repeatCount='indefinite'/>
</path>
<circle fill={color} cx="36" cy="18" r="1">
<animateTransform attributeName='transform' type='rotate' from='0 18 18' to='360 18 18' dur='0.9s' repeatCount='indefinite'/>
</circle>
</g>
</g>
</svg>
</Icon> : null
)
};
export default Spinner;
💻 Spinner.stories.js
import Spinner from "../components/Spinner"
export default {
title: 'Component/Spinner',
component: Spinner,
argTypes: {
size: {
defaultValue: 24,
control: { type: 'number'}
},
color: {
control: 'color'
}
}
};
export const Default = (args) => {
return (
<Spinner {...args} />
);
};
🖨 완성본
💻 Toggle/index.js
import styled from "@emotion/styled";
import useToggle from "../../hooks/useToggle";
const ToggleContainer = styled.label`
display: inline-block;
cursor: pointer;
user-select: none;
`
const ToggleSwitch = styled.div`
width: 64px;
height: 30px;
padding: 2px;
border-radius:15px;
background-color: #ccc;
transition: background-color 0.2 ease-out;
box-sizing: border-box;
&:after {
content: '';
position: relative;
left: 0;
display: block;
width: 26px;
height: 26px;
border-radius: 50%;
background-color: white;
transition: left 0.2s ease-out;
}
`
const ToggleInput = styled.input`
display: none;
&:checked + div {
background: lightgreen;
}
&:checked + div:after {
left: calc(100% - 26px);
}
&:disabled + div {
opacity: 0.7;
cursor: not-allowed;
&:after {
opacity:0.7;
}
}
`
const Toggle = ({
name,
on = false,
disabled,
onChange,
...props
}) => {
const [checked, toggle] = useToggle(on);
const handleChange = (e) => {
toggle();
onChange && onChange();
}
return (
<ToggleContainer {...props}>
<ToggleInput type="checkbox" name={name} checked={checked} disabled={disabled} onChange={handleChange} />
<ToggleSwitch />
</ToggleContainer>
);
};
export default Toggle;
💻 hooks/useToggle.js
import { useCallback, useState } from "react";
const useToggle = (initialState = false) => {
const [state, setState] = useState(initialState);
const toggle = useCallback(() => setState((state) => !state), []);
return [state, toggle];
};
export default useToggle;
💻 Toggle.stories.js
import Toggle from "../components/Toggle";
export default {
title: 'Component/Toggle',
component: Toggle,
argTypes: {
disabled: {control: 'boolean'},
}
}
export const Default = (args) => {
return <Toggle {...args} />;
};
🖨 완성본
기본적인 컴포넌트들을 직접 만들며 스토리에 쌓아두니 든든하다.
차근차근 스킬을 늘리며 추후에 언제든지 활용할 수 있도록 더 다양한 컴포넌트를 제작해야겠다.