[react] 컴포넌트 연습

young-gue Park·2023년 3월 9일
0

React

목록 보기
6/17
post-thumbnail

⚡ react 컴포넌트 연습


📌 컴포넌트 제작

⭐ Text

  1. 텍스트에 굵기, 크기, 색깔 변화가 가능하고 밑줄, 취소선, 마크 표시를 할 수 있다.
  2. block 혹은 inline 설정을 통해 div 태그 내에 넣을지 p 혹은 span 태그 내에 넣을지 선택이 가능하다.
  3. code 설정을 통해 code 형식으로 텍스트를 넣을 수 있다.

💻 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

  1. h1 ~ h6 까지 설정이 가능하다.
  2. 굵기, 색깔 변화가 가능하고 밑줄 표시를 할 수 있다.

💻 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

  1. 이미지의 가로 세로 길이 및 화면 출력 형식을 설정할 수 있다.
  2. 이미지로 띄울 사진들과 사진이 로딩되기 전의 placeholder 사진이 따로 있다.
  3. 컴포넌트 내부 훅을 이용해 lazy 속성을 통한 사진 늦게 출력하기 기능이 있다.
    (화면 상에 해당 사진이 얼마나 노출되느냐에 따라 placeholder가 출력되어야할 사진으로 바뀌게 설정할 수 있다.)

💻 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

  1. 하위 컴포넌트와 요소를 조작하여 자동으로 간격이 생기게 만든다.
  2. 가로 정렬, 세로 정렬 모두 적용이 가능하다.

💻 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

  1. 로딩중 화면 구성에 사용한다.
  2. 스피너의 색과 크기 조절이 가능하다.

💻 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

  1. 체크 박스와 동일한 기능에 껐다 킬 수 있는 버튼 모양의 컴포넌트이다.
  2. 체크 박스의 형태는 숨겨두었다.
  3. 만약 Toggle 버튼이 비활성화되면 누를 수 없게 설정되어있다.
  4. 이벤트 핸들링을 위한 훅을 사용한다.

💻 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} />;
};

🖨 완성본


기본적인 컴포넌트들을 직접 만들며 스토리에 쌓아두니 든든하다.
차근차근 스킬을 늘리며 추후에 언제든지 활용할 수 있도록 더 다양한 컴포넌트를 제작해야겠다.

profile
Hodie mihi, Cras tibi

0개의 댓글