022

๐Ÿ˜‰ ํ—ท๊ฐˆ๋ ธ๋˜ & ๋ชฐ๋ž๋˜ ๋ถ€๋ถ„๋“ค๋งŒ ์ •๋ฆฌํ•˜๋Š” ๋‚˜๋งŒ์˜ 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๋กœ ๋ถ„๋ฆฌํ•ด์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค๋‹ˆ ๋„ˆ๋ฌด ์‹ ๊ธฐํ•˜๋‹ค ๐Ÿ˜Š





์ถœ์ฒ˜

ํ”„๋กœ๊ทธ๋ž˜๋จธ์Šค

React.js Document

storybook Document

์นดํ…Œ๊ณ ๋ฆฌ: ,

์—…๋ฐ์ดํŠธ:

๋Œ“๊ธ€๋‚จ๊ธฐ๊ธฐ