Skip to content

Commit

Permalink
feat(toast): 토스트 추가 작업 (#42)
Browse files Browse the repository at this point in the history
* feat: 사용자 지정 스타일 받기

* feat: Toast 스토리북 Docs 업데이트

* feat: 스토리북 공통 파라미터 분리

* feat: 토스트 함수 중복 호출 방지

* feat: Toast close 함수 추가

---------

Co-authored-by: solar3070 <>
  • Loading branch information
solar3070 authored Jan 29, 2024
1 parent fe7726e commit 81ee600
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 78 deletions.
8 changes: 8 additions & 0 deletions apps/docs/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const preview: Preview = {
date: /Date$/i,
},
},
layout: 'centered',
backgrounds: {
default: 'dark', // 기본 배경을 'dark'로 설정
values: [
{ name: 'dark', value: "#0F1012" }, // 'dark' 배경의 색상을 검정색으로 지정
{ name: 'white', value: '#ffffff' }
],
},
},
};

Expand Down
10 changes: 0 additions & 10 deletions apps/docs/src/stories/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,6 @@ export default {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark', // 기본 배경을 'dark'로 설정
values: [
{ name: 'dark', value: "#0F1012" }, // 'dark' 배경의 색상을 검정색으로 지정
{ name: 'white', value: '#ffffff' }
],
},
},
} as Meta<ButtonStoryProps>;

// 기본 버튼 스토리
Expand Down
10 changes: 0 additions & 10 deletions apps/docs/src/stories/CheckBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,6 @@ import CheckBox from 'ui/CheckBox';
const meta = {
title: 'Components/CheckBox',
component: CheckBox,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark', // 기본 배경을 'dark'로 설정
values: [
{ name: 'dark', value: "#0F1012" }, // 'dark' 배경의 색상을 검정색으로 지정
{ name: 'white', value: '#ffffff' },
],
},
},
tags: ['autodocs'],
} as Meta<typeof CheckBox>;

Expand Down
10 changes: 0 additions & 10 deletions apps/docs/src/stories/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,6 @@ export default {
title: 'Components/Dialog',
component: Dialog,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark', // 기본 배경을 'dark'로 설정
values: [
{ name: 'dark', value: '#0F1012' }, // 'dark' 배경의 색상을 검정색으로 지정
{ name: 'white', value: '#ffffff' },
],
},
},
decorators: [
(Story: StoryFn) => (
<DialogProvider>
Expand Down
85 changes: 65 additions & 20 deletions apps/docs/src/stories/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { Meta, StoryObj, StoryFn } from "@storybook/react";
import { Button } from "ui";
import { useToast, ToastProvider, type ToastOptionType } from "ui";
import { IconArchive } from "../../../../packages/icons/src";
import ToastComponent from "ui/Toast/Toast";

const meta: Meta = {
title: "Components/Toast",
component: Button,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark', // 기본 배경을 'dark'로 설정
values: [
{ name: 'dark', value: "#0F1012" }, // 'dark' 배경의 색상을 검정색으로 지정
{ name: 'white', value: '#ffffff' },
],
component: ToastComponent,
tags: ["autodocs"],
argTypes: {
icon: {
control: "radio",
options: ["success", "alert", "error", "custom"],
description: "토스트의 아이콘을 지정합니다.",
mapping: { custom: <IconArchive /> },
table: {
defaultValue: { summary: "success" },
type: { summary: "success | alert | error | ReactElement" },
},
},
content: { description: "토스트의 내용을 작성합니다." },
action: {
description: "토스트의 액션을 지정합니다.",
table: { type: { summary: "object" } },
},
style: {
description: "토스트의 스타일을 사용자가 지정합니다.",
table: { type: { summary: "object" } },
},
},
decorators: [
Expand All @@ -27,53 +38,87 @@ const meta: Meta = {
};
export default meta;

type Story = StoryObj;
export const Component: StoryObj = {
args: {
icon: "success",
content: "Default Toast",
action: { name: "보러가기", onClick: () => {} },
style: {
root: { position: "static", animation: "none", transform: "none" },
},
},
};

const ToastSample = ({ option }: { option: ToastOptionType }) => {
const { open } = useToast();
return <button onClick={() => open(option)}>Open Toast</button>;
};

export const DefaultSuccess: Story = {
export const DefaultSuccess: StoryObj = {
name: "Default - Success",
argTypes: { icon: { control: { disable: true } } },
render: () => {
const option: ToastOptionType = { icon: "success", content: "기본 토스트" };
const option: ToastOptionType = {
icon: "success",
content: "기본 토스트입니다.",
};
return <ToastSample option={option} />;
},
};

export const TextOverAlert: Story = {
export const TextOverAlert: StoryObj = {
name: "Text Over - Alert",
argTypes: { icon: { control: { disable: true } } },
render: () => {
const option: ToastOptionType = {
icon: "alert",
content:
"두 줄을 넘었습니다. 두 줄을 넘었습니다. 두 줄을 넘었습니다. 두 줄을 넘었습니다. 두 줄을 넘었습니다. 두 줄을 넘었습니다. 두 줄을 넘었습니다. ",
"토스트 내용은 두 줄을 초과할 수 없습니다. 토스트 내용은 두 줄을 초과할 수 없습니다. 토스트 내용은 두 줄을 초과할 수 없습니다. ",
};
return <ToastSample option={option} />;
},
};

export const ActionButtonError: Story = {
export const ActionButtonError: StoryObj = {
name: "Action Button - Error",
argTypes: { icon: { control: { disable: true } } },
render: () => {
const option: ToastOptionType = {
icon: "error",
content: "액션 버튼이 있는 토스트",
content: "액션 버튼이 있는 토스트입니다.",
action: { name: "보러가기", onClick: () => {} },
};
return <ToastSample option={option} />;
},
};

export const ActionButtonCustomIcon: Story = {
export const ActionButtonCustomIcon: StoryObj = {
name: "Action Button - Custom Icon",
argTypes: { icon: { control: { disable: true } } },
render: () => {
const option: ToastOptionType = {
icon: <IconArchive />,
content: "커스텀 아이콘을 사용한 토스트",
content: "커스텀 아이콘을 사용한 토스트입니다.",
action: { name: "보러가기", onClick: () => {} },
};
return <ToastSample option={option} />;
},
};

export const CloseToast: StoryObj = {
name: "CloseToast",
argTypes: { icon: { control: { disable: true } } },
render: () => {
const { open, close } = useToast();

Check failure on line 112 in apps/docs/src/stories/Toast.stories.tsx

View workflow job for this annotation

GitHub Actions / Release

React Hook "useToast" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"
const option: ToastOptionType = {
icon: "alert",
content: "토스트를 원하는 타이밍에 닫을 수 있습니다.",
};
return (
<>
<button onClick={() => open(option)}>Open Toast</button>
<button onClick={close}>Close Toast</button>
</>
);
},
};
13 changes: 8 additions & 5 deletions packages/ui/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, { forwardRef } from "react";
import * as Toast from "./parts";
import { ToastOptionType } from "./types";

function ToastComponent({ icon, content, action }: ToastOptionType) {
function ToastComponent(props: ToastOptionType, ref: React.Ref<HTMLDivElement>) {
const { icon = "success", content, action, style } = props;

return (
<Toast.Root icon={icon}>
<Toast.Content content={content} />
{action && <Toast.Action {...action} />}
<Toast.Root ref={ref} style={style?.root} icon={icon}>
<Toast.Content style={style?.content} content={content} />
{action && <Toast.Action style={style?.action} {...action} />}
</Toast.Root>
);
}

export default ToastComponent;
export default forwardRef(ToastComponent);
27 changes: 19 additions & 8 deletions packages/ui/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,39 @@ import { Children, ToastOptionType } from "./types";

export const ToastContext = createContext({
openToast(option: ToastOptionType) {},
closeToast() {},
});

function ToastProvider({ children }: Children) {
const [toastOption, setToastOption] = useState<ToastOptionType | null>(null);
const toastTimer = useRef<NodeJS.Timeout>();
const timerRef = useRef<NodeJS.Timeout>();
const toastRef = useRef<HTMLDivElement>();

const openToast = (option: ToastOptionType) => {
setToastOption(option);
if (toastOption) return;

if (toastTimer.current) {
clearTimeout(toastTimer.current);
}
setToastOption(option);
const timer = setTimeout(() => {
setToastOption(null);
}, 4000);
toastTimer.current = timer;
timerRef.current = timer;
};

const closeToast = () => {
if (!toastOption) return;

clearTimeout(timerRef.current);
setTimeout(() => setToastOption(null), 200);
if (toastRef.current) {
toastRef.current.style.opacity = "0";
toastRef.current.style.transition = "opacity .2s linear";
}
};

return (
<ToastContext.Provider value={{ openToast }}>
<ToastContext.Provider value={{ openToast, closeToast }}>
{children}
{toastOption && <ToastComponent {...toastOption} />}
{toastOption && <ToastComponent ref={toastRef} {...toastOption} />}
</ToastContext.Provider>
);
}
Expand Down
40 changes: 27 additions & 13 deletions packages/ui/Toast/parts/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import { ActionType, DefaultIconType, StrictPropsWithChildren } from "../types";
import * as styles from "./style.css";
import { ToastIconSuccess, ToastIconAlert, ToastIconError } from "../icons";
import { forwardRef } from "react";

// ============================== ToastRoot ===============================

interface RootProps {
icon: DefaultIconType | React.ReactElement;
}

const convertToIcon = {
success: ToastIconSuccess,
alert: ToastIconAlert,
error: ToastIconError,
};

function Root({ children, icon }: StrictPropsWithChildren<RootProps>) {
interface RootProps {
icon: DefaultIconType | React.ReactElement;
style?: React.CSSProperties;
}

function Root(props: StrictPropsWithChildren<RootProps>, ref: React.Ref<HTMLDivElement>) {
const { children, icon, style } = props;
const isDefaultIcon = typeof icon === "string";
const DefaultIcon = isDefaultIcon ? convertToIcon[icon] : undefined;

return (
<div className={styles.root}>
<div ref={ref} className={styles.root} style={style} >
{DefaultIcon ? (
<DefaultIcon />
) : (
Expand All @@ -30,24 +33,35 @@ function Root({ children, icon }: StrictPropsWithChildren<RootProps>) {
);
}

const WrappedRoot = forwardRef(Root);

// ============================== ToastContent ===============================

function Content(props: { content: string }) {
const { content } = props;
interface ContentProps {
content: string;
style?: React.CSSProperties;
}

function Content(props : ContentProps) {
const { content, style } = props;

return <p className={styles.content}>{content}</p>;
return <p className={styles.content} style={style}>{content}</p>;
}

// ============================== ToastAction ===============================

function Action(props: ActionType) {
const { name, ...actionProps } = props;
interface ActionProps extends ActionType {
style?: React.CSSProperties;
}

function Action(props: ActionProps) {
const { name, style, ...actionProps } = props;

return (
<button className={styles.action} {...actionProps}>
<button className={styles.action} style={style} {...actionProps}>
{name}
</button>
);
}

export { Root, Content, Action };
export { WrappedRoot as Root, Content, Action };
1 change: 1 addition & 0 deletions packages/ui/Toast/parts/style.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const toastAnimation = keyframes({

export const root = style({
animation: `${toastAnimation} 4s`,
animationFillMode: "forwards",

display: "flex",
alignItems: "center",
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/Toast/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ export type ActionType = {
name: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

export type Styles = "root" | "content" | "action";

export type StyleType = {
[key in Styles]?: React.CSSProperties;
};

export type ToastOptionType = {
icon: DefaultIconType | React.ReactElement;
icon?: DefaultIconType | React.ReactElement;
content: string;
action?: ActionType;
style?: StyleType,
};
5 changes: 4 additions & 1 deletion packages/ui/Toast/useToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { ToastContext } from "./ToastProvider";
import { ToastOptionType } from "./types";

const useToast = () => {
const { openToast } = useContext(ToastContext);
const { openToast, closeToast } = useContext(ToastContext);

return {
open(option: ToastOptionType) {
openToast(option);
},
close() {
closeToast();
}
};
};

Expand Down

0 comments on commit 81ee600

Please sign in to comment.