Skip to content

Commit de6ea6b

Browse files
authored
Merge pull request #195 from DevKor-github/feat/#87/toast
ํ† ์ŠคํŠธ ๊ตฝ๊ธฐ
2 parents 4d61968 + c4b6601 commit de6ea6b

File tree

14 files changed

+255
-9
lines changed

14 files changed

+255
-9
lines changed

โ€Žpanda.config.tsโ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export default defineConfig({
4646
navigator: { value: 500 },
4747
drawerBackLayer: { value: 600 },
4848
drawerBody: { value: 700 },
49+
toast: { value: 800 },
4950
},
5051
sizes: PANDA_CSS_CONSTANTS,
5152
spacing: PANDA_CSS_CONSTANTS,

โ€Žsrc/App.tsxโ€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import 'react-time-picker-typescript/dist/style.css';
55

66
import SocketProvider from './common/components/SocketProvider';
7+
import { OverlayProvider } from '@/common/components/OverlayProvider';
78

89
const queryClient = new QueryClient({
910
defaultOptions: {
@@ -23,7 +24,9 @@ function App() {
2324

2425
return (
2526
<QueryClientProvider client={queryClient}>
26-
<SocketProvider>{router}</SocketProvider>
27+
<SocketProvider>
28+
<OverlayProvider>{router}</OverlayProvider>
29+
</SocketProvider>
2730
</QueryClientProvider>
2831
);
2932
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createContext, type ReactNode } from 'react';
2+
3+
export const OverlayContext = createContext<{
4+
mount(id: string, element: ReactNode): void;
5+
unmount(id: string): void;
6+
} | null>(null);
7+
if (process.env.NODE_ENV !== 'production') {
8+
OverlayContext.displayName = 'OverlayContext';
9+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { CreateOverlayElement } from '@/common/hooks/useOverlay';
2+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState, type Ref } from 'react';
3+
4+
interface Props {
5+
overlayElement: CreateOverlayElement;
6+
onExit: () => void;
7+
}
8+
9+
export interface OverlayControlRef {
10+
close: () => void;
11+
}
12+
13+
export const OverlayController = forwardRef(function OverlayController(
14+
{ overlayElement: OverlayElement, onExit }: Props,
15+
ref: Ref<OverlayControlRef>,
16+
) {
17+
const [isOpenOverlay, setIsOpenOverlay] = useState(false);
18+
19+
const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);
20+
21+
useImperativeHandle(ref, () => {
22+
return { close: handleOverlayClose };
23+
}, [handleOverlayClose]);
24+
25+
useEffect(() => {
26+
// NOTE: requestAnimationFrame์ด ์—†์œผ๋ฉด ๊ฐ€๋” Open ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์‹คํ–‰๋˜์ง€ ์•Š๋Š”๋‹ค.
27+
requestAnimationFrame(() => {
28+
setIsOpenOverlay(true);
29+
});
30+
}, []);
31+
32+
return <OverlayElement isOpen={isOpenOverlay} close={handleOverlayClose} exit={onExit} />;
33+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { OverlayContext } from '@/common/components/OverlayProvider/OverlayContext';
2+
import React, { useCallback, useMemo, useState, type PropsWithChildren, type ReactNode } from 'react';
3+
4+
export function OverlayProvider({ children }: PropsWithChildren) {
5+
const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map());
6+
7+
const mount = useCallback((id: string, element: ReactNode) => {
8+
setOverlayById(overlayById => {
9+
const cloned = new Map(overlayById);
10+
cloned.set(id, element);
11+
return cloned;
12+
});
13+
}, []);
14+
15+
const unmount = useCallback((id: string) => {
16+
setOverlayById(overlayById => {
17+
const cloned = new Map(overlayById);
18+
cloned.delete(id);
19+
return cloned;
20+
});
21+
}, []);
22+
23+
const context = useMemo(() => ({ mount, unmount }), [mount, unmount]);
24+
25+
return (
26+
<OverlayContext.Provider value={context}>
27+
{children}
28+
{[...overlayById.entries()].map(([id, element]) => (
29+
<React.Fragment key={id}>{element}</React.Fragment>
30+
))}
31+
</OverlayContext.Provider>
32+
);
33+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { motion } from 'motion/react';
2+
3+
import * as s from './style.css';
4+
5+
import { TOAST_ANIMATION_DURATION } from '@/common/hooks/useToast';
6+
7+
interface ToastProps {
8+
isOpen: boolean;
9+
message: string;
10+
}
11+
const Toast = ({ isOpen, message }: ToastProps) => {
12+
return (
13+
<div className={s.ToastLayout}>
14+
<motion.div
15+
className={s.ToastContainer}
16+
initial={{ opacity: 0, translateY: 66 }}
17+
animate={isOpen ? { opacity: 1, translateY: 0 } : { opacity: 0, translateY: 66 }}
18+
transition={{ duration: TOAST_ANIMATION_DURATION / 1000, type: 'spring' }}
19+
>
20+
{message}
21+
</motion.div>
22+
</div>
23+
);
24+
};
25+
26+
export default Toast;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { css } from '@styled-system/css';
2+
3+
export const ToastLayout = css({
4+
position: 'absolute',
5+
bottom: 0,
6+
left: '50%',
7+
transform: 'translate3d(-50%,0,0)',
8+
display: 'flex',
9+
justifyContent: 'center',
10+
zIndex: 'toast',
11+
});
12+
13+
export const ToastContainer = css({
14+
display: 'flex',
15+
alignItems: 'center',
16+
justifyContent: 'center',
17+
marginBottom: '1.25rem',
18+
19+
width: 'fit-content',
20+
maxW: '20rem',
21+
borderRadius: '6.25rem',
22+
padding: '0.75rem 2rem',
23+
24+
background: 'systemGray4',
25+
backdropFilter: 'blur(4px)',
26+
27+
color: '100',
28+
fontSize: '0.875rem',
29+
letterSpacing: '-0.035rem',
30+
lineHeight: '1.4',
31+
fontWeight: 400,
32+
whiteSpace: 'nowrap',
33+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2+
import { OverlayContext } from '@/common/components/OverlayProvider/OverlayContext';
3+
import { OverlayController, type OverlayControlRef } from '@/common/components/OverlayProvider/OverlayController';
4+
5+
export type CreateOverlayElement = (props: { isOpen: boolean; close: () => void; exit: () => void }) => ReactNode;
6+
7+
let elementId = 1;
8+
9+
interface Options {
10+
exitOnUnmount?: boolean;
11+
}
12+
13+
export function useOverlay({ exitOnUnmount = true }: Options = {}) {
14+
const context = useContext(OverlayContext);
15+
16+
if (context === null) {
17+
throw new Error('useOverlay is only available within OverlayProvider.');
18+
}
19+
20+
const { mount, unmount } = context;
21+
const [id] = useState(() => String(elementId++));
22+
23+
const overlayRef = useRef<OverlayControlRef | null>(null);
24+
25+
useEffect(() => {
26+
return () => {
27+
if (exitOnUnmount) {
28+
unmount(id);
29+
}
30+
};
31+
}, [exitOnUnmount, id, unmount]);
32+
33+
return useMemo(
34+
() => ({
35+
open: (overlayElement: CreateOverlayElement) => {
36+
mount(
37+
id,
38+
<OverlayController
39+
// NOTE: state should be reset every time we open an overlay
40+
key={Date.now()}
41+
ref={overlayRef}
42+
overlayElement={overlayElement}
43+
onExit={() => {
44+
unmount(id);
45+
}}
46+
/>,
47+
);
48+
},
49+
close: () => {
50+
overlayRef.current?.close();
51+
},
52+
exit: () => {
53+
unmount(id);
54+
},
55+
}),
56+
[id, mount, unmount],
57+
);
58+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useCallback, useRef } from 'react';
2+
import { useOverlay } from '@/common/hooks/useOverlay';
3+
import Toast from '@/common/components/Toast';
4+
5+
export const TOAST_ANIMATION_DURATION = 300;
6+
export const TOAST_DISPLAY_DURATION = 1000 + TOAST_ANIMATION_DURATION;
7+
8+
interface OpenToastOption {
9+
message: string;
10+
}
11+
export function useToast() {
12+
// toast๋ฅผ motion.div๋กœ ๊ตฌํ˜„ํ•˜๋‹ˆ, ๋ฐ”๋กœ exit ์‹œ์ผœ๋„ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋˜๋Š” ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค๋งŒ..
13+
// ๋งŒ์ผ์„ ์œ„ํ•ด transition์ด ๋ณด์—ฌ์งˆ ์ˆ˜ ์žˆ๋„๋ก close์™€ exit ๋กœ์ง์„ ๋ถ„๋ฆฌํ•˜์˜€์Šต๋‹ˆ๋‹ค
14+
const overlay = useOverlay({ exitOnUnmount: false });
15+
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16+
const exitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
17+
18+
const initTimeout = useCallback(() => {
19+
// ๊ฐ์ข… ํƒ€์ด๋จธ๋“ค์„ ์ดˆ๊ธฐํ™” ํ•ฉ๋‹ˆ๋‹ค
20+
if (closeTimeoutRef.current !== null) {
21+
clearTimeout(closeTimeoutRef.current);
22+
}
23+
24+
if (exitTimeoutRef.current !== null) {
25+
clearTimeout(exitTimeoutRef.current);
26+
}
27+
}, []);
28+
29+
const handleClose = useCallback(() => {
30+
overlay.close();
31+
exitTimeoutRef.current = setTimeout(overlay.exit, TOAST_ANIMATION_DURATION);
32+
}, [overlay]);
33+
34+
const openToast = ({ message }: OpenToastOption) => {
35+
initTimeout();
36+
closeTimeoutRef.current = setTimeout(handleClose, TOAST_DISPLAY_DURATION);
37+
overlay.open(({ isOpen }) => <Toast isOpen={isOpen} message={message} />);
38+
};
39+
40+
return { openToast };
41+
}

โ€Žsrc/features/home/components/Filter/PriceFilter/CustomPriceInput.tsxโ€Ž

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
22
import * as s from './style.css';
33
import { MAX_PRICE } from '@/libs/constants';
44
import { useSearchParams } from 'react-router';
5+
import { useToast } from '@/common/hooks/useToast';
56

67
interface Props {
78
active: boolean;
89
}
910
const CustomPriceInput = ({ active }: Props) => {
11+
const { openToast } = useToast();
1012
const [searchParams, setSearchParams] = useSearchParams();
1113

1214
const defaultStartPrice = searchParams.get('start-price') ? Number(searchParams.get('start-price')) : NaN;
@@ -42,7 +44,7 @@ const CustomPriceInput = ({ active }: Props) => {
4244
const submitPrice = () => {
4345
if (isNaN(startPrice) || isNaN(endPrice)) return;
4446
if (startPrice > endPrice) {
45-
alert('์‹œ์ž‘ ๊ฐ€๊ฒฉ์ด ๋ ๊ฐ€๊ฒฉ๋ณด๋‹ค ํด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
47+
openToast({ message: '์‹œ์ž‘ ๊ฐ€๊ฒฉ์ด ๋ ๊ฐ€๊ฒฉ๋ณด๋‹ค ํด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' });
4648
return;
4749
}
4850
setSearchParams(prev => {

0 commit comments

Comments
ย (0)