컴포넌트 연습하기
Text
//Text.js
import "./Text.css";
import PropTypes from "prop-types";
const Text = ({
children,
size,
paragraph,
block,
strong,
underline,
delete: del,
color,
mark,
code,
...props
}) => {
const Tag = block ? "div" : paragraph ? "p" : "span";
const fontStyle = {
fontWeight: strong && "bold",
fontSize: typeof size === "number" && size,
textDecoration: underline && "underline",
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}`}`}
style={{ ...props.style, ...fontStyle }}
{...props}
>
{children}
</Tag>
);
};
Text.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
strong: PropTypes.bool,
underline: PropTypes.bool,
delete: PropTypes.bool,
style: PropTypes.object,
};
export default Text;
//Text.stories.js
import Text from "../components/Text";
export default {
title: "Component/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" },
code: { control: "boolean" },
mark: { control: "boolean" },
},
};
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={24}>
Text
</Text>
;
</>
);
};
//Text.css
.Text--size-small {
font-size: 12px;
}
.Text--size-normal {
font-size: 14px;
}
.Text--size-large {
font-size: 18px;
}
Header
//Header.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",
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,
style: PropTypes.object,
};
export default Header;
//Header.stories.js
import Header from "../components/Header/Header";
export default {
title: "Comonent/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
//Image.js
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
let observer = null;
const LOAD_IMG_EVENT = "loadImage";
const onIntersection = (entries, io) => { //커스텀이벤트
entries.forEach((entry) => {
if (entry.isIntersecting) {
io.unobserve(entry.target);
entry.target.dispatchEvent(new CustomEvent(LOAD_IMG_EVENT));
}
});
};
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",
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, handleLoadImage);
return () => {
imgElement &&
imgElement.removeEventListener(LOAD_IMG_EVENT, handleLoadImage);
};
}, [lazy]);
useEffect(() => {
if (!lazy) {
return;
}
if (!observer) {
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,
placeholder: PropTypes.string,
src: PropTypes.string.isRequired,
block: PropTypes.bool,
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/Image";
export default {
title: "Component/Image",
component: Image,
argTypes: {
lazy: {
defaultValue: false,
control: { type: "boolean" },
},
threshold: {
type: { name: "number" },
defaultValue: 0.5,
control: { type: "number" }
},
placeholder: {
type: { name: "string" },
defaultValue: "https://via.placeholder.com/200",
control: { type: "text" }
},
src: {
control: { type: "text" },
type: { name: "string", require: true },
defaultValue: "https://picsum.photos/200"
},
block: {
defaultValue: false,
control: { type: "boolean"}
},
width: {
defaultValue: 200,
control: { type: "range", min: 200, max: 600 },
},
height: {
defaultValue: 200,
control: { type: "range", min: 200, max: 600 },
},
alt: {
control: "string"
},
mode: {
defaultValue: "cover",
options: ["cover", "fill", "contain"],
control: { type: "inline-radio" },
},
},
args: {
src: "https://picsum.photos/200",
width: 500,
placeholder: "https://via.placeholder.com/200",
threshold: 0.5,
lazy: false,
},
};
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 {...args} lazy block src={`${args.src}?${i}`} key={i} />
))}
</div>
);
};
+) IntersectionObserver Api 알아보기
spacer
//Spacer.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",
};
const nodes = React.Children.toArray(children) //자식 컴포넌트
.filter((element) => React.isValidElement(element))
.map((element, idx, elements) => {
return React.cloneElement(element, {
...element.props,
style: {
...element.props.style,
marginRight:
type === "horizontal" && idx !== elements.length - 1 && size,
marginBottom:
type === "vertical" && idx !== elements.length - 1 && size,
},
});
});
return (
<div {...props} style={{ ...spacerStyle }}>
{nodes}
</div>
);
};
export default Spacer;
//Spacer.stories.js
import Spacer from "../components/Spacer/Spacer";
export default {
title: "Component/Space",
component: Spacer,
argTypes: {
size: {
defaultValue: 8,
control: { type: "range", min: 8, max: 64 },
},
},
args: {
size: 8,
},
};
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>
);
};
nodes에서 사용한 메서드
- React.Children.toArray: 각 자식에 key가 할당된 배열을 children 불투명(opaque) 자료 구조로 반환한다. render() 메서드에서 children의 집합을 다루고 싶을 때, 특히 this.props.children을 하부로 전달하기 전에 다시 정렬하거나 일부만 잘라내고 싶을 때에 유용하다.
- React.isvalidelement : 객체가 React 엘리먼트인지 확인한다. true 또는 false를 반환한다.
- React.cloneElement(element, [props], [...children]) : 엘리먼트를 조작하는 데 사용되는 React 최상위 API의 일부이다. 첫 번째 인수를 시작점으로 사용하여 새 엘리먼트를 복제하고 반환, 불필요한 중복 코드를 피하면서 상위 컴포넌트의 하위 요소를 추가하거나 수정하려는 경우에 유용
spinner
//Spinner.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/Spinner";
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
//Toggle.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.2s 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;
//Toggle.stories.js
import Toggle from "../components/Toggle/Toggle"
export default {
title: "Component/Toggle",
component: Toggle,
argTypes: {
disabled: {control: 'boolean'}
}
}
export const Default = (args) => {
return <Toggle {...args} />
}
//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;
'데브코스 프론트엔드 5기 > React' 카테고리의 다른 글
231205 [Day56] React(2) (1) | 2023.12.12 |
---|---|
231204 [Day55] React(1) (0) | 2023.12.10 |