[DAY-50] React (3)
๐ ํท๊ฐ๋ ธ๋ & ๋ชฐ๋๋ ๋ถ๋ถ๋ค๋ง ์ ๋ฆฌํ๋ ๋๋ง์ TIL
๐ฏ ๋ชจ๋ ๊ฐ์ ๋ด์ฉ์ ์ ์ง ์์์!
์ค๋์ ์๊ฐ์?
์ค๋์ ์ค์ต์ ์ด์ฌํ ์งํํ๋๋ผ ๋ฐ๋นด๋ค.
์ปดํฌ๋ํธ ๋จ์๊ฐ ์๊ฐ๋ณด๋ค ์๋ค๋ ๊ฒ์ ์ ์ ์์๋ค.
[1] ์ปดํฌ๋ํธ ์ฐ์ต
๋ฆฌ์ํธ๋ฅผ ์ ์ฐ๊ธฐ ์ํด์๋ ์ปดํฌ๋ํธ๋ฅผ ์ ๋ง๋ค ์ค ์์์ผ ํ๋ค.
์ด๋ป๊ฒ ํ๋ฉด ์ปดํฌ๋ํธ๋ฅผ ์ ๋ถ๋ฆฌํ ์ ์์์ง, ์ปดํฌ๋ํธ ํธ๋ฆฌ์ ๊ตฌ์กฐ๋ฅผ ํจ์จ์ ์ผ๋ก ์ค๊ณํ ์ ์์์ง
์์ง ๊ฐ ๊ธธ์ ๋ฉ์ง๋ง ์ฐจ๊ทผ์ฐจ๊ทผ ํ์ตํด๋ณด์.
- Text Component
// index.js
import "./Text.css";
import PropTypes from "prop-types";
const Text = ({
children,
block,
paragraph,
size,
strong,
underline,
delete: del,
color,
mark,
code,
...prop
}) => {
const Tag = block ? "div" : paragraph ? "p" : "span";
const fontSize = typeof size === "number" ? size : undefined;
const fontStyle = {
fontWeight: strong ? "bold" : undefined,
fontSize,
textDecoration: underline ? "underline" : undefined,
color,
};
if (mark) {
children = <mark>{children}</mark>;
}
if (code) {
children = <code>{children}</code>;
}
if (del) {
children = <del>{children}</del>;
}
return (
<Tag
className={typeof size === "string" ? `Text--size-${size}` : undefined}
style={{ ...prop.style, ...fontStyle }}
{...prop}
>
{children}
</Tag>
);
};
Text.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
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 ".";
export default {
title: "Component/Text",
component: Text,
argTypes: {
size: { control: "number" },
strong: { control: "boolean" },
underline: { control: "boolean" },
delete: { control: "boolean" },
block: { control: "boolean" },
paragraph: { control: "boolean" },
mark: { control: "boolean" },
code: { control: "boolean" },
color: { control: "color" },
},
};
export const Default = (args) => {
return <Text {...args}>Text</Text>;
};
export const Size = (args) => {
return (
<>
<Text {...args} size="large">
Large
</Text>
<Text {...args} size="normal">
Normal
</Text>
<Text {...args} size="small">
Small
</Text>
</>
);
};
- Header Component
// 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,
strong: PropTypes.bool,
underline: PropTypes.bool,
color: PropTypes.string,
};
export default Header;
// Header.stories.js
import Header from ".";
export default {
title: "Component/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 Component
// index.js
import PropTypes from "prop-types";
import { useEffect, useRef, useState } 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,
};
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 }}
{...props}
/>
);
};
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;
import Image from ".";
export default {
title: "Component/Image",
component: Image,
argTypes: {
src: {
name: "src",
type: { name: "string", require: true },
defaultValue: "https://picsum.photos/200",
control: { type: "text" },
},
placeholder: {
name: "placeholder",
type: { name: "string", require: true },
defaultValue: "https://via.placeholder.com/200",
control: { type: "text" },
},
threshold: {
type: { name: "number" },
defaultValue: 0.5,
control: { type: "number" },
},
lazy: {
defaultValue: false,
control: { type: "boolean" },
},
block: {
defaultValue: false,
control: { type: "boolean" },
},
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} />;
};
export const Lazy = (args) => {
return (
<div>
{Array.from(new Array(20), (_, k) => k).map((i) => (
<Image {...args} lazy block src={`${args.src}?${i}`} key={i} />
))}
</div>
);
};
- Spacer Component
// 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 ".";
export default {
title: "Component/Spacer",
component: Spacer,
argTypes: {
size: {
defaultValue: 8,
control: { type: "range", min: 8, max: 64 },
},
},
};
const Box = ({ style, block }) => {
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 Component
// 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"
xmins="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;
// Spiner.stories.js
import Spinner from ".";
export default {
title: "Component/Spinner",
component: Spinner,
argTypes: {
size: {
defaultValue: 24,
control: "number",
},
color: {
control: "color",
},
loading: {
defaultValue: true,
control: "boolean",
},
},
};
export const Default = (args) => {
return <Spinner {...args} />;
};
- Toggle Component
// 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 = true, onChange, ...props }) => {
const [checked, toggle] = useToggle(on);
const handleChange = () => {
toggle();
onChange && onChange();
};
return (
<ToggleContainer {...props}>
<ToggleInput
type="checkbox"
name={name}
checked={checked}
disabled={disabled}
onChange={handleChange}
/>
<ToggleSwitch />
</ToggleContainer>
);
};
export default Toggle;
// Toggle.stories.js
import Toggle from ".";
export default {
title: "Component/Toggle",
component: Toggle,
argTypes: {
disabled: {
control: "boolean",
},
},
};
export const Default = (args) => {
return <Toggle {...args} />;
};
Spacer๋ฅผ Component๋ก ๋ถ๋ฆฌํด์ ์ฌ์ฉํ ์ ์๋ค๋ ๋๋ฌด ์ ๊ธฐํ๋ค ๐
๋๊ธ๋จ๊ธฐ๊ธฐ