Untitled

๐Ÿ˜‰ ํ—ท๊ฐˆ๋ ธ๋˜ & ๋ชฐ๋ž๋˜ ๋ถ€๋ถ„๋“ค๋งŒ ์ •๋ฆฌํ•˜๋Š” ๋‚˜๋งŒ์˜ TIL
๐Ÿ˜ฏ ๋ชจ๋“  ๊ฐ•์˜ ๋‚ด์šฉ์€ ์ ์ง€ ์•Š์•„์š”!

์˜ค๋Š˜์˜ ์†Œ๊ฐ์€?
์‹ค์Šต ์ง„ํ–‰ํ•˜๋‹ค๊ฐ€ ์ฃฝ์„ ๋ป” ํ–ˆ์Šต๋‹ˆ๋‹ค..
์˜คํƒ€ ์žก์•„์ฃผ๋Š” extension ์ฐพ์•„๋ด์•ผ๊ฒ ์–ด์š”.
ํ—ˆ๊ตฌํ•œ ๋‚  ์˜คํƒ€ ๋•Œ๋ฌธ์— ํ•œ์‹œ๊ฐ„ ์”ฉ ๋‚ ๋ฆฌ๋Š” ์‚ฌ๋žŒ ๋ˆ„๊ตฌ๋ผ๊ณ ? ๋„ค ์ ‘๋‹ˆ๋‹ค.
์Šค์ผˆ๋ ˆํ†ค ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๊ฐ€์žฅ ์‹ ๊ธฐํ–ˆ๋Š”๋ฐ์š”.
์Šค์ผˆ๋ ˆํ†ค ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋จผ์ € ๋ Œ๋”๋ง ๋œ ํ›„, loading์ด ๋๋‚˜๋ฉด ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ๋กœ ๊ต์ฒดํ•ด์ฃผ๋Š” ๊ฑธ๊นŒ์š”?
ํ”„๋กœ์ ํŠธ์—์„œ ํ™œ์šฉํ•ด๋ณด๊ณ  ์‹ถ์–ด์š”..!




[1] Component ์‹ค์Šต

Untitled 1

ํƒ€์ž.. ์ €๋งŒ ๋Š๋ ค์š”?

์ €๋„ ๋งˆ์šฐ์Šค ์•ˆ์“ฐ๊ณ  ํƒ€์ž ์น˜๊ณ  ์‹ถ์–ด์š” ์—‰์—‰



Avatar


// index.js
import styled from "@emotion/styled";
import { useEffect, useState } from "react";
import ImageComponent from "../Image";
import AvatarGroup from "./AvatarGroup";

const ShapeToCssValue = {
  circle: "50%",
  round: "4px",
  square: "0px",
};

const AvatarWrapper = styled.div`
  position: relative;
  display: inline-block;
  border: 1px solid #dadada;
  border-radius: ${({ shape }) => ShapeToCssValue[shape]};
  background-color: #eee;
  overflow: hidden;
  > img {
    transition: opacity 0.2s ease-out;
  }
`;

const Avatar = ({
  lazy,
  threshold,
  src,
  size = 70,
  shape = "circle", // round, square
  placeholder,
  alt,
  mode = "cover",
  __TYPE,
  ...props
}) => {
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    const image = new Image();
    image.src = src;
    image.onload = () => setLoaded(true);
  }, [src]);

  return (
    <AvatarWrapper {...props} shape={shape}>
      <ImageComponent
        block
        lazy={lazy}
        threshold={threshold}
        width={size}
        height={size}
        src={src}
        placeholder={placeholder}
        alt={alt}
        mode={mode}
        style={{ opacity: loaded ? 1 : 0 }}
      />
    </AvatarWrapper>
  );
};

Avatar.defaultProps = {
  __TYPE: "Avatar",
};

Avatar.propTypes = {
  __TYPE: "Avatar",
};

Avatar.Group = AvatarGroup;

export default Avatar;


// AvatarGroup,js
import React from "react";

const AvatarGroup = ({ children, shape = "circle", size = 70, ...props }) => {
  const avatars = React.Children.toArray(children)
    .filter((element) => {
      return !!React.isValidElement(element);
    })
    .map((avatar, index, avatars) => {
      return React.cloneElement(avatar, {
        ...avatar.props,
        size,
        shape,
        style: {
          ...avatar.props.style,
          marginLeft: -size / 5,
          zIndex: avatars.length - index,
        },
      });
    });

  return (
    <div style={{ paddingLeft: size / 5 }} {...props}>
      {avatars}
    </div>
  );
};

export default AvatarGroup;

// Avatar.stories.js
import Avatar from "../../components/_BasicComponent/Avatar";

export default {
  title: "Component/Avatar",
  component: Avatar,
  argTypes: {
    src: { defaultValue: "https://picsum.photos/200" },
    shape: {
      defaultValue: "circle",
      control: "inline-radio",
      options: ["circle", "round", "sqaure"],
    },
    size: {
      defaultValue: 70,
      control: { type: "range", min: 40, max: 200 },
    },
    mode: {
      defaultValue: "cover",
      control: "inline-radio",
      options: ["contain", "cover", "fill"],
    },
  },
};

export const Default = (args) => <Avatar {...args} />;

export const Group = () => {
  return (
    <Avatar.Group size={40}>
      <Avatar src="https://picsum.photos/200?1" />
      <Avatar src="https://picsum.photos/200?2" />
      <Avatar src="https://picsum.photos/200?3" />
      <Avatar src="https://picsum.photos/200?4" />
    </Avatar.Group>
  );
};



Slider


// index.js
import { useState, useCallback, useEffect, useRef } from "react";
import styled from "@emotion/styled";

const SliderContainer = styled.div`
  position: relative;
  width: 100%;
  height: 16px;
`;

const Handle = styled.div`
  position: absolute;
  top: 8px;
  left: 0;
  width: 12px;
  height: 12px;
  transform: translate(-50%, -50%);
  border: 2px solid #44b;
  border-radius: 50%;
  background-color: white;
  cursor: grab;
`;

const Track = styled.div`
  position: absolute;
  top: 6px;
  left: 0;
  width: 0;
  height: 4px;
  border-radius: 2px;
  background-color: #44b;
`;

const Rail = styled.div`
  position: absolute;
  top: 6px;
  left: 0;
  width: 100%;
  height: 4px;
  border-radius: 2px;
  background-color: #aaa;
`;

const Slider = ({ min, max, step = 0.1, defaultValue, onChange, ...props }) => {
  const sliderRef = useRef(null);
  const [dragging, setDragging] = useState(false);
  const [value, setValue] = useState(defaultValue ? defaultValue : min);

  const handleMouseDown = useCallback(() => {
    setDragging(true);
  }, []);

  const handleMouseUp = useCallback(() => {
    setDragging(false);
  }, []);

  useEffect(() => {
    const handleMouseMove = (e) => {
      if (!dragging) return;

      const handleOffset = e.pageX - sliderRef.current.offsetLeft;
      const sliderWidth = sliderRef.current.offsetWidth;

      const track = handleOffset / sliderWidth;
      let newValue;
      if (track < 0) {
        newValue = min;
      } else if (track > 1) {
        newValue = max;
      } else {
        newValue = Math.round((min + (max - min) * track) / step) * step;
        newValue = Math.min(max, Math.max(min, newValue));
      }

      setValue(newValue);
      onChange && onChange(newValue);
    };

    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp);
    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
      document.removeEventListener("mouseup", handleMouseUp);
    };
  }, [value, min, max, dragging, sliderRef, handleMouseUp, onChange, step]);

  const percentage = ((value - min) / (max - min)) * 100;

  return (
    <SliderContainer ref={sliderRef} {...props}>
      <Rail />
      <Track style={{ width: `${percentage}%` }} />
      <Handle
        onMouseDown={handleMouseDown}
        style={{ left: `${percentage}%` }}
      />
    </SliderContainer>
  );
};

export default Slider;


// Slider.stories.js
import Slider from "../../components/_BasicComponent/Slider";
import Spacer from "../../components/_BasicComponent/Spacer";
import Icon from "../../components/_BasicComponent/Icon";

export default {
  title: "Component/Slider",
  component: Slider,
  argTypes: {
    defaultValue: { defaultValue: 1, control: "number" },
    min: { defaultValue: 1, control: "number" },
    max: { defaultValue: 100, control: "number" },
    step: { defaultValue: 0.1, control: "number" },
    onChange: { action: "onChange" },
  },
};

export const Default = (args) => <Slider {...args} />;

export const VolumeControl = (args) => {
  return (
    <Spacer>
      <Icon name="volume" />
      <Slider style={{ width: 100, display: "inline-block" }} {...args} />
      <Icon name="volume-2" />
    </Spacer>
  );
};



Progress


// index.js
import styled from "@emotion/styled";

const ProgressContainer = styled.div`
  position: relative;
  width: 100%;
  height: 16px;
`;

const Track = styled.div`
  position: absolute;
  top: 6px;
  left: 0;
  width: 0;
  height: 4px;
  border-radius: 2px;
  background-color: #44b;
  background-size: 20px 20px;
  background-image: linear-gradient(
    45deg,
    rgba(255, 255, 255, 0.15) 25%,
    transparent 25%,
    transparent 50%,
    rgba(255, 255, 255, 0.15) 50%,
    rgba(255, 255, 255, 0.15) 75%,
    transparent 75%,
    transparent 100%
  );
  animation: move 1000ms linear infinite;
  transition: width 100ms linear;

  @keyframes move {
    from {
      background-position: 0 0;
    }
    to {
      background-position: 40px 0;
    }
  }
`;

const Rail = styled.div`
  position: absolute;
  top: 6px;
  left: 0;
  width: 100%;
  height: 4px;
  border-radius: 2px;
  background-color: #aaa;
`;

const Progress = ({ value, ...props }) => {
  return (
    <ProgressContainer {...props}>
      <Rail />
      <Track style={{ width: `${value}%` }} />
    </ProgressContainer>
  );
};

export default Progress;

// Progress.stories.js
import { useState } from "react";
import Progress from "../../components/_BasicComponent/Progress";

export default {
  title: "Component/Progress",
  component: Progress,
  argTypes: {
    value: { defaultValue: 10, control: "number" },
  },
};

export const Default = (args) => {
  const [value, setValue] = useState(20);

  return (
    <div>
      <button onClick={() => setValue(100)}>Change Value</button>
      <Progress {...args} value={value} />
    </div>
  );
};



Divider


// index.js
import styled from "@emotion/styled";

const Line = styled.hr`
  border: none;
  background-color: #aaa;

  &.vertical {
    position: relative;
    top: -1;
    display: inline-block;
    width: 1px;
    height: 13px;
    vertical-align: middle;
  }

  &.horizontal {
    display: block;
    width: 100%;
    height: 1px;
  }
`;

const Divider = ({ type = "horizontal", size = 8, ...props }) => {
  const dividerStyle = {
    margin: type === "vertical" ? `0 ${size}px` : `${size}px 0`,
  };
  return (
    <Line
      {...props}
      className={type}
      style={{ ...dividerStyle, ...props.style }}
    />
  );
};

export default Divider;

// Divider.stories.js
import Divider from "../../components/_BasicComponent/Divider";
import Text from "../../components/_BasicComponent/Text";

export default {
  title: "Component/Divider",
  component: Divider,
};

export const Horizontal = () => {
  return (
    <>
      <Text>์œ„</Text>
      <Divider type="horizontal" />
      <Text>์•„๋ž˜</Text>
    </>
  );
};

export const Vertical = () => {
  return (
    <>
      <Text>์œ„</Text>
      <Divider type="vertical" />
      <Text>์•„๋ž˜</Text>
    </>
  );
};



Skeleton

Skeleton์ด ๋ฌด์—‡์ธ๊ณ  ํ•˜๋‹ˆ ๋ฐ”๋กœ ์ด๊ฒƒ์ด์—ˆ๋‹ค.

Untitled 2

๋ฌด์–ธ๊ฐ€ ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฏธ๋ฆฌ ์•Œ๋ ค์ฃผ๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์˜€๋‹ค..! ๐Ÿ˜ฎ


// index.js
import Box from "./Box";
import Circle from "./Circle";
import Paragraph from "./Paragraph";

const Skeleton = {
  Box,
  Circle,
  Paragraph,
};

export default Skeleton;
// Base.js
import styled from "@emotion/styled";

const Base = styled.div`
  display: inline-block;
  border-radius: 4px;
  background-image: linear-gradient(
    90deg,
    #dfe3e8 0px,
    #efefef 40px,
    #dfe3e8 80px
  );
  background-size: 200% 100%;
  background-position: 0 center;
  animation: skeleton--zoom-in 0.2s ease-out, skeleton--loading 2s infinite
      linear;
  @keyframes skeleton--zoom-in {
    0% {
      opacity: 0;
      transform: scale(0.95);
    }
    100% {
      opacity: 1;
      transform: scale(1);
    }
  }
  @keyframes skeleton--loading {
    0% {
      background-position-x: 100%;
    }
    50% {
      background-position-x: -100%;
    }
    100% {
      background-position-x: -100%;
    }
  }
`;

export default Base;

// Circle.js
import styled from "@emotion/styled";
import Base from "./Base";

const CircularBase = styled(Base)`
  border-radius: 50%;
`;

const Circle = ({ size }) => (
  <CircularBase style={{ width: size, height: size }} />
);

export default Circle;
// Box.js
import Base from "./Base";

const Box = ({ width, height }) => <Base style={{ width, height }} />;

export default Box;

// Paragraph.js
import Box from "./Box";

const Paragraph = ({ line = 3, ...props }) => {
  return (
    <div {...props}>
      {Array.from(Array(line), (_, index) =>
        index !== line - 1 ? (
          <Box width="100%" height={16} key={index} />
        ) : (
          <Box width="64%" height={16} key={index} />
        )
      )}
    </div>
  );
};

export default Paragraph;

// Skeleton.stories.js
import Skeleton from "../../components/_BasicComponent/Skeleton";
export default {
  title: "Component/Skeleton",
};

export const Box = (args) => <Skeleton.Box {...args} />;
Box.argTypes = {
  width: { defaultValue: 200, control: "number" },
  height: { defaultValue: 100, control: "number" },
};

export const Circle = (args) => <Skeleton.Circle {...args} />;
Circle.argTypes = {
  size: { defaultValue: 100, control: "number" },
};

export const Paragraph = (args) => <Skeleton.Paragraph {...args} />;
Paragraph.argTypes = {
  line: { defaultValue: 3, control: "number" },
};

export const Sample = () => {
  return (
    <>
      <div>
        <div style={{ float: "left", marginRight: 16 }}>
          <Skeleton.Circle size={60} />
        </div>
        <div style={{ float: "left", width: "80%" }}>
          <Skeleton.Paragraph line={4} />
        </div>
        <div style={{ clear: "both" }} />
      </div>
    </>
  );
};



Input

// index.js
import styled from "@emotion/styled";

const Wrapper = styled.div`
  display: ${({ block }) => (block ? "block" : "inline-block")};
`;

const Label = styled.label`
  display: block;
  font-size: 12px;
`;

const StyledInput = styled.input`
  width: 100%;
  padding: 4px 8px;
  border: 1px solid ${({ invalid }) => (invalid ? "red" : "gray")};
  border-radius: 4px;
  box-sizing: border-box;
`;

const Input = ({
  label,
  block = false,
  invalid = false,
  required = false,
  disabled = false,
  readonly = false,
  wrapperProps,
  ...props
}) => {
  return (
    <Wrapper block={block} {...wrapperProps}>
      <Label>{label}</Label>
      <StyledInput
        invalid={invalid}
        required={required}
        disabled={disabled}
        readOnly={readonly}
        {...props}
      />
    </Wrapper>
  );
};

export default Input;
// Input.stories.js
import Input from "../../components/_BasicComponent/Input";

export default {
  title: "Component/Input",
  component: Input,
  argTypes: {
    label: {
      defaultValue: "Label",
      control: "text",
    },
    block: {
      defaultValue: false,
      control: "boolean",
    },
    invalid: {
      defaultValue: false,
      control: "boolean",
    },
    required: {
      defaultValue: false,
      control: "boolean",
    },
    disabled: {
      defaultValue: false,
      control: "boolean",
    },
    readonly: {
      defaultValue: false,
      control: "boolean",
    },
  },
};

export const Default = (args) => <Input {...args} />;



Select

// index.js
import styled from "@emotion/styled";

const Wrapper = styled.div`
  display: ${({ block }) => (block ? "block" : "inline-block")};
`;

const Label = styled.label`
  display: block;
  font-size: 12px;
`;

const StyledSelect = styled.select`
	width: 100%;
	padding: 4px 8px;
	border: 1px solid ${({ invalid }) => (invalid ? "red" : "gray")}
	border-radius: 4px;
	box-sizing: border-box;
`;

const Select = ({
  data,
  label,
  placeholder,
  block = false,
  invalid = false,
  required = false,
  disabled = false,
  wrapperaProps,
  ...props
}) => {
  const formattedDate = data.map((item) =>
    typeof item === "string" ? { label: item, value: item } : item
  );

  const options = formattedDate.map((item) => (
    <option key={item.value} value={item.value}>
      {item.label}
    </option>
  ));

  if (placeholder) {
    options.unshift(
      <option key="placeholder" value="" hidden>
        {placeholder}
      </option>
    );
  }

  return (
    <Wrapper block={block} {...wrapperaProps}>
      <Label>{label}</Label>
      <StyledSelect
        invalid={invalid}
        required={required}
        disabled={disabled}
        {...props}
      >
        {options}
      </StyledSelect>
    </Wrapper>
  );
};

export default Select;
// Select.stories.js
import Select from "../../components/_BasicComponent/Select";

export default {
  title: "Component/Select",
  component: Select,
  argTypes: {
    label: {
      defaultValue: "Label",
      control: "text",
    },
    placeholder: {
      defaultValue: "Placeholder",
      control: "text",
    },
    block: {
      defaultValue: false,
      control: "boolean",
    },
    invalid: {
      defaultValue: false,
      control: "boolean",
    },
    disabled: {
      defaultValue: false,
      control: "boolean",
    },
    required: {
      defaultValue: false,
      control: "boolean",
    },
  },
};

export const Default = (args) => (
  <Select
    data={["Item 1", "Item 2", { label: "Item 3", value: "value" }]}
    {...args}
  />
);





์ถœ์ฒ˜

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

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

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

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