Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Toast 컴포넌트 추가 #52

Merged
merged 10 commits into from
Jan 12, 2025
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
"@repo/theme": "workspace:^",
"@repo/ui": "workspace:^",
"next": "14.2.21",
"overlay-kit": "^1.4.1",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@repo/theme": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@repo/ui": "workspace:*",
"@types/node": "^20.11.24",
"@types/react": "18.3.0",
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import localFont from 'next/font/local';
import './globals.css';
import '@repo/theme/styles';
import '@repo/ui/styles';
import { ThemeProvider } from '@repo/theme';
import { Providers } from '../components/Providers/Providers';

const geistSans = localFont({
src: './fonts/GeistVF.woff',
Expand All @@ -29,8 +29,7 @@ export default function RootLayout({
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider theme="light">{children}</ThemeProvider>{' '}
{/** TODO: 추후 시스템 감지 설정 추가 예정 */}
<Providers>{children}</Providers>
</body>
</html>
);
Expand Down
32 changes: 31 additions & 1 deletion apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import { Icon, Text } from '@repo/ui';
'use client';

import { Icon, Toast, Text } from '@repo/ui';
import { overlay } from 'overlay-kit';

export default function Home() {
const notify1 = () =>
overlay.open(({ isOpen, close, unmount }) => (
<Toast
open={isOpen}
onClose={close}
leftAddon={<Toast.Icon toastType="success" />}
onExited={unmount}
>
생성된 본문이 업데이트 됐어요!
</Toast>
));

minseong0324 marked this conversation as resolved.
Show resolved Hide resolved
const notify2 = () =>
overlay.open(({ isOpen, close, unmount }) => (
<Toast
open={isOpen}
onClose={close}
leftAddon={<Toast.Icon toastType="error" />}
onExited={unmount}
>
1개 이상의 게시물을 선택해주세요
</Toast>
));

return (
<div>
웹 1팀 파이팅!
<Icon size={24} name="stack" type="stroke" />
<Icon size={24} name="stack" type="fill" />
<Icon size={24} name="stack" type="stroke" color="warning300" />
<button onClick={notify1}>success 토스트 열기</button>
<button onClick={notify2}>warning 토스트 열기</button>
<Text.H1 color="grey950" fontSize={28} fontWeight="semibold">
hihi
</Text.H1>
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/Providers/Providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';

import { ThemeProvider } from '@repo/theme';
import { OverlayProvider } from 'overlay-kit';
import { ReactNode } from 'react';

type ProvidersProps = {
children: ReactNode;
};

export function Providers({ children }: ProvidersProps) {
return (
<ThemeProvider theme="light">
<OverlayProvider>{children}</OverlayProvider>
</ThemeProvider>
);
}
6 changes: 6 additions & 0 deletions packages/theme/src/themes/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export type ThemeContract = {
blue200: string;
blue400: string;
blue800: string;

violet0: string;
violet100: string;
violet200: string;
violet400: string;
violet800: string;
};
space: {
[K in keyof typeof tokens.spacing]: string;
Expand Down
6 changes: 6 additions & 0 deletions packages/theme/src/themes/dark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export const darkTheme: ThemeContract = {
blue200: tokens.colors.blue200,
blue400: tokens.colors.blue400,
blue800: tokens.colors.blue800,

violet0: tokens.colors.violet0,
violet100: tokens.colors.violet100,
violet200: tokens.colors.violet200,
violet400: tokens.colors.violet400,
violet800: tokens.colors.violet800,
},
space: tokens.spacing,
borderRadius: tokens.radius,
Expand Down
6 changes: 6 additions & 0 deletions packages/theme/src/themes/light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export const lightTheme: ThemeContract = {
blue200: tokens.colors.blue200,
blue400: tokens.colors.blue400,
blue800: tokens.colors.blue800,

violet0: tokens.colors.violet0,
violet100: tokens.colors.violet100,
violet200: tokens.colors.violet200,
violet400: tokens.colors.violet400,
violet800: tokens.colors.violet800,
},
space: tokens.spacing,
borderRadius: tokens.radius,
Expand Down
6 changes: 6 additions & 0 deletions packages/theme/src/tokens/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export const colors = {
blue400: '#7698E2',
blue800: '#153C66',

violet0: '#F2F3FF',
violet100: '#DFE1FE',
violet200: '#B7B4FF',
violet400: '#817ED5',
violet800: '#4A46A3',

grey0: '#FFFFFF',
grey25: '#F7F9FB',
grey50: '#EAEFF4',
Expand Down
1 change: 1 addition & 0 deletions packages/ui/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const buildOptions = {
}),
],
loader: { '.css': 'file' },
allowOverwrite: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 없으니까 빌드가 갑자기 터지더라구요..? 이유가 뭘까요..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

원래 esbuild에서 빌드 후 파일 덮어쓰기를 할 때 해당 옵션이 없으면 에러가 발생할 수 있다는데... 그럼 지금까지는 왜 됐을까요? 기기괴괴...
저 옵션을 쓰고 싶지 않으면 빌드 전마다 dist 파일을 비우고 빌드를 시작하는 방법으로 해결할 수 있대요! 저희는 그대로 둬도 될 것 같긴 합니다,,,

outdir,
external: ['react'],
};
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
},
"dependencies": {
"@repo/theme": "workspace:^",
"@types/react-dom": "18.3.1",
"@vanilla-extract/css": "^1.17.0",
"@vanilla-extract/recipes": "^0.5.5",
"motion": "^11.17.0",
"react": "^18"
},
"devDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/src/components/Toast/Toast.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';
import { tokens } from '@repo/theme';

export const container = style({
position: 'fixed',
bottom: 40,
right: 40,
padding: `${tokens.spacing[20]} ${tokens.spacing[32]}`,
borderRadius: 100,
backgroundColor: tokens.colors.grey700,
color: tokens.colors.grey0,
});

export const content = style({
display: 'flex',
alignItems: 'center',
gap: tokens.spacing[8],
});

export const message = style({
fontSize: tokens.typography.fontSize[20],
fontWeight: tokens.typography.fontWeight.semibold,
lineHeight: '24px',
});
141 changes: 141 additions & 0 deletions packages/ui/src/components/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { motion, AnimatePresence, HTMLMotionProps } from 'motion/react';
import {
ForwardRefExoticComponent,
ReactNode,
forwardRef,
useEffect,
KeyboardEvent,
useRef,
} from 'react';
import { ToastIcon } from './compounds/Icon/Icon';
import * as styles from './Toast.css';
import { useTimer } from './hooks/useTimer';
import { mergeRefs } from '@/utils';

export type ToastType = 'default' | 'success' | 'error';

export type ToastProps = {
/**
* 토스트 타입
* @default 'default'
*/
toastType?: ToastType;
/**
* 왼쪽 추가 요소 (아이콘 등)
*/
leftAddon?: ReactNode;
/**
* 토스트 지속 시간
* @default 2000
*/
duration?: number;
/**
* 자식 요소
*/
children?: ReactNode;
/**
* 토스트 열기 여부
*/
open: boolean;
/**
* 토스트가 열릴 때 호출되는 함수
*/
onOpen?: VoidFunction;
/**
* 토스트가 닫힐 때 호출되는 함수
*/
onClose?: VoidFunction;
/**
* 토스트가 완전히 닫힌 후 호출되는 함수
*/
onExited?: VoidFunction;
} & Omit<HTMLMotionProps<'div'>, 'children'>;

const ToastComponent = forwardRef<HTMLDivElement, ToastProps>(
(
{
toastType = 'default',
leftAddon,
duration = 2000,
children,
open,
onOpen,
onClose,
onExited,
style: toastStyle,
...restProps
},
ref
) => {
minseong0324 marked this conversation as resolved.
Show resolved Hide resolved
const { startCurrentTimer, clearCurrentTimeout } = useTimer({
onTimerEnd: onClose,
timeoutSecond: duration,
});
kongnayeon marked this conversation as resolved.
Show resolved Hide resolved
const toastRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (open) {
onOpen?.();
startCurrentTimer();
toastRef.current?.focus();
}
}, [open, onOpen, startCurrentTimer]);

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose?.();
}
};

return (
<AnimatePresence onExitComplete={onExited}>
{open && (
<motion.div
ref={mergeRefs(ref, toastRef)}
className={styles.container}
initial={{ y: '120%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '120%', opacity: 0 }}
transition={{
type: 'spring',
damping: 25,
stiffness: 400,
opacity: {
duration: 0.15,
ease: 'easeInOut',
},
}}
onPointerEnter={clearCurrentTimeout}
onPointerLeave={startCurrentTimer}
style={{
...toastStyle,
}}
role="alert"
aria-live="polite"
tabIndex={0}
onKeyDown={handleKeyDown}
{...restProps}
>
<div className={styles.content}>
{leftAddon ?? (
<ToastIcon toastType={toastType} aria-hidden="true" />
)}
<span className={styles.message}>{children}</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
);

type ToastComposition = {
Icon: typeof ToastIcon;
};

export const Toast: ForwardRefExoticComponent<ToastProps> & ToastComposition =
Object.assign(ToastComponent, {
Icon: ToastIcon,
});

Toast.displayName = 'Toast';
38 changes: 38 additions & 0 deletions packages/ui/src/components/Toast/compounds/Icon/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Icon } from '@repo/ui';
import type { IconProps } from '@repo/ui';
import { ToastType } from '../../Toast';

export type ToastIconProps = Omit<IconProps, 'name'> & {
toastType?: ToastType;
};

export function ToastIcon({
toastType = 'default',
...restProps
}: ToastIconProps) {
const iconName = (() => {
switch (toastType) {
case 'success':
return 'check';
case 'error':
return 'notice';
default:
return null;
}
})();

if (!iconName) {
return null;
}

const iconColor = (() => {
switch (toastType) {
case 'success':
return 'violet200';
case 'error':
return 'warning300';
}
})();

return <Icon type="fill" name={iconName} color={iconColor} {...restProps} />;
}
Loading
Loading