Skip to content

Commit

Permalink
Merge pull request #140 from k35o/toast
Browse files Browse the repository at this point in the history
トースト機能の作成
  • Loading branch information
k35o committed Jul 31, 2024
2 parents 25cb1b4 + 07e6d56 commit a9e1145
Show file tree
Hide file tree
Showing 16 changed files with 441 additions and 6 deletions.
9 changes: 6 additions & 3 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import type { Preview } from '@storybook/react';
import '../src/app/_styles/globals.css';
import { AppProvider } from '../src/providers/app';
import { M_PLUS_2 } from 'next/font/google';

const font = M_PLUS_2({ subsets: ['latin'] });
Expand All @@ -17,9 +18,11 @@ const preview: Preview = {
},
decorators: [
(Story) => (
<div className={font.className}>
<Story />
</div>
<AppProvider>
<div className={font.className}>
<Story />
</div>
</AppProvider>
),
],
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@vercel/speed-insights": "1.0.12",
"autoprefixer": "10.4.19",
"clsx": "2.1.1",
"framer-motion": "11.3.19",
"next": "14.2.5",
"postcss": "8.4.40",
"react": "18.3.1",
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
import { Button } from '@/components/button';
import { Heading } from '@/components/heading';
import { ClipboardIcon } from '@heroicons/react/24/solid';
import { useClipboard } from '@/hooks/clipboard';

export const Result: FC = () => {
const id = useId();
const fixedText = useFixedText();
const resetResult = useResetResult();
const isCheckResult = useConvertIncomplete();
const { writeClipboard } = useClipboard();

return (
<div className="flex flex-col items-center justify-center gap-8">
Expand All @@ -32,7 +34,7 @@ export const Result: FC = () => {
修正後のテキスト
</Heading>
<Button
onClick={() => navigator.clipboard.writeText(fixedText)}
onClick={() => writeClipboard(fixedText)}
endIcon={<ClipboardIcon title="" className="h-6 w-6" />}
>
テキストをコピーする
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
AccordionPanel,
} from '@/components/accordion';
import { Button } from '@/components/button';
import { useClipboard } from '@/hooks/clipboard';

export const VerifiedSyntax: FC = () => {
const text = useText();
const resetResult = useResetResult();
const { writeClipboard } = useClipboard();

return (
<div className="flex flex-col items-center justify-center gap-8">
Expand All @@ -25,7 +27,7 @@ export const VerifiedSyntax: FC = () => {
戻る
</Button>
<Button
onClick={() => navigator.clipboard.writeText(text)}
onClick={() => writeClipboard(text)}
endIcon={<ClipboardIcon title="" className="h-6 w-6" />}
>
テキストをコピーする
Expand Down
5 changes: 4 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { M_PLUS_2 } from 'next/font/google';
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
import '@/libs/zod';
import { AppProvider } from '@/providers/app';

const font = M_PLUS_2({
subsets: ['latin'],
Expand Down Expand Up @@ -32,7 +33,9 @@ export default function RootLayout({
return (
<html lang="ja">
<body className={font.className}>
<GlobalLayout>{children}</GlobalLayout>
<AppProvider>
<GlobalLayout>{children}</GlobalLayout>
</AppProvider>
<Analytics />
<SpeedInsights />
</body>
Expand Down
1 change: 1 addition & 0 deletions src/components/toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ToastProvider, useToast } from './provider';
142 changes: 142 additions & 0 deletions src/components/toast/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use client';

import { AnimatePresence, motion, Variants } from 'framer-motion';
import { StatusType } from '@/types';
import {
createContext,
Dispatch,
FC,
PropsWithChildren,
SetStateAction,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { Toast } from './toast';
import { uuidV4 } from '@/utils/uuid-v4';

type ToastType = {
id: string;
status: StatusType;
message: string;
};

const SetToastContext = createContext<
Dispatch<SetStateAction<ToastType[]>> | undefined
>(undefined);

export const useToast = () => {
const setToasts = useContext(SetToastContext);
if (!setToasts) {
throw new Error('useToast must be used within a ToastProvider');
}

const onOpen = useCallback(
(status: StatusType, message: string) => {
setToasts((prev) => [
...prev,
{
id: uuidV4(),
status,
message,
},
]);
},
[setToasts],
);

const onClose = useCallback(
(id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
},
[setToasts],
);

const onCloseAll = useCallback(() => {
setToasts([]);
}, [setToasts]);

return {
onOpen,
onClose,
onCloseAll,
};
};

const toastMotionVariants: Variants = {
initial: {
opacity: 0,
y: 24,
},
animate: {
opacity: 1,
y: 0,
x: 0,
scale: 1,
transition: {
duration: 0.4,
ease: [0.4, 0, 0.2, 1],
},
},
exit: {
opacity: 0,
scale: 0.85,
transition: {
duration: 0.2,
ease: [0.4, 0, 1, 1],
},
},
};

export const ToastProvider: FC<PropsWithChildren> = ({
children,
}) => {
const [toasts, setToasts] = useState<ToastType[]>([]);
const ref = useRef<HTMLElement | null>(null);

useEffect(() => {
ref.current = document.body;
}, []);

return (
<SetToastContext.Provider value={setToasts}>
{children}
{ref.current
? createPortal(
<div
role="region"
aria-live="polite"
aria-label="通知"
className="fixed bottom-3 z-50 flex w-full flex-col items-center justify-center gap-4"
>
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<motion.div
key={toast.id}
layout
variants={toastMotionVariants}
initial="initial"
animate="animate"
exit="exit"
custom={{ position: 'bottom' }}
>
<div
role="status"
aria-atomic={true}
className="shadow-lg"
>
<Toast {...toast} />
</div>
</motion.div>
))}
</AnimatePresence>
</div>,
ref.current,
)
: null}
</SetToastContext.Provider>
);
};
52 changes: 52 additions & 0 deletions src/components/toast/toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ToastProvider, useToast } from './provider';
import { Button } from '../button';
import { Toast } from './toast';

const meta: Meta<typeof ToastProvider> = {
title: 'components/toast',
component: ToastProvider,
decorators: [
(Story) => (
<ToastProvider>
<Story />
</ToastProvider>
),
],
render: () => {
const { onOpen } = useToast();
return (
<Button
onClick={() => onOpen('success', 'トーストを呼びました')}
>
トーストを呼ぶ
</Button>
);
},
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof ToastProvider>;

export const Primary: Story = {};

export const ToastSuccess: Story = {
render: () => (
<Toast id="1" status="success" message="成功しました" />
),
};

export const ToasInfo: Story = {
render: () => <Toast id="1" status="info" message="情報です" />,
};

export const ToastError: Story = {
render: () => (
<Toast id="1" status="error" message="失敗しました" />
),
};

export const ToastWarning: Story = {
render: () => <Toast id="1" status="warning" message="警告です" />,
};
26 changes: 26 additions & 0 deletions src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { StatusType } from '@/types';
import { FC, useCallback } from 'react';
import { Alert } from '../alert';
import { useToast } from './provider';
import { useTimeout } from '@/hooks/timeout';

type ToastProps = {
id: string;
status: StatusType;
message: string;
};

const DELAY_MS = 5000;

export const Toast: FC<ToastProps> = ({ id, status, message }) => {
const { onClose } = useToast();

useTimeout(
useCallback(() => {
onClose(id);
}, [id, onClose]),
DELAY_MS,
);

return <Alert status={status} message={message} />;
};
Loading

0 comments on commit a9e1145

Please sign in to comment.