데브코스 프론트엔드 5기/React

231206 [Day57] React(3)

코딩하는 키티 2023. 12. 13. 16:32

컴포넌트 연습하기

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