diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 0f464a11..4b2f7b4d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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'] }); @@ -17,9 +18,11 @@ const preview: Preview = { }, decorators: [ (Story) => ( -
- -
+ +
+ +
+
), ], }; diff --git a/package.json b/package.json index e1b752c2..b6d33830 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18e83c21..da37032e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: clsx: specifier: 2.1.1 version: 2.1.1 + framer-motion: + specifier: 11.3.19 + version: 11.3.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: specifier: 14.2.5 version: 14.2.5(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3683,6 +3686,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@11.3.19: + resolution: {integrity: sha512-+luuQdx4AsamyMcvzW7jUAJYIKvQs1KE7oHvKkW3eNzmo0S+3PSDWjBuQkuIP9WyneGnKGMLUSuHs8OP7jKpQg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -11113,6 +11130,13 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@11.3.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + tslib: 2.6.3 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fresh@0.5.2: {} fromentries@1.3.2: {} diff --git a/src/app/characters/check-syntax/_components/result/result.tsx b/src/app/characters/check-syntax/_components/result/result.tsx index adb6c92f..f2791edd 100644 --- a/src/app/characters/check-syntax/_components/result/result.tsx +++ b/src/app/characters/check-syntax/_components/result/result.tsx @@ -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 (
@@ -32,7 +34,7 @@ export const Result: FC = () => { 修正後のテキスト + ); + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = {}; + +export const ToastSuccess: Story = { + render: () => ( + + ), +}; + +export const ToasInfo: Story = { + render: () => , +}; + +export const ToastError: Story = { + render: () => ( + + ), +}; + +export const ToastWarning: Story = { + render: () => , +}; diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx new file mode 100644 index 00000000..a5c20529 --- /dev/null +++ b/src/components/toast/toast.tsx @@ -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 = ({ id, status, message }) => { + const { onClose } = useToast(); + + useTimeout( + useCallback(() => { + onClose(id); + }, [id, onClose]), + DELAY_MS, + ); + + return ; +}; diff --git a/src/hooks/clipboard/index.test.ts b/src/hooks/clipboard/index.test.ts new file mode 100644 index 00000000..7c190b25 --- /dev/null +++ b/src/hooks/clipboard/index.test.ts @@ -0,0 +1,55 @@ +import { act, renderHook } from '@testing-library/react'; +import { useClipboard } from '.'; + +const onOpenMockFn = vi.fn(); + +vi.mock('@/components/toast', () => ({ + useToast: () => ({ + onOpen: onOpenMockFn, + }), +})); + +describe('useClipboard', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('クリップボードを書き込める', async () => { + const writeText = 'test'; + const writeTextMockFn = vi.fn(); + vi.stubGlobal('navigator', { + clipboard: { + writeText: writeTextMockFn, + }, + }); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await result.current.writeClipboard(writeText); + }); + + expect(onOpenMockFn).toBeCalledWith( + 'success', + 'クリップボードにコピーしました', + ); + expect(onOpenMockFn).toHaveBeenCalledOnce(); + expect(writeTextMockFn).toBeCalledWith(writeText); + expect(navigator.clipboard.writeText).toHaveBeenCalledOnce(); + }); + + it('クリップボードを読み込める', async () => { + const readTextMockFn = vi.fn(); + vi.stubGlobal('navigator', { + clipboard: { + readText: readTextMockFn, + }, + }); + + const { result } = renderHook(() => useClipboard()); + act(async () => { + await result.current.readClipboard(); + }); + + expect(readTextMockFn).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/clipboard/index.ts b/src/hooks/clipboard/index.ts new file mode 100644 index 00000000..ef3a8eb3 --- /dev/null +++ b/src/hooks/clipboard/index.ts @@ -0,0 +1,24 @@ +import { useToast } from '@/components/toast'; +import { useCallback } from 'react'; + +export const useClipboard = () => { + const { onOpen } = useToast(); + + const writeClipboard = useCallback( + async (text: string) => { + await navigator.clipboard.writeText(text); + onOpen('success', 'クリップボードにコピーしました'); + }, + [onOpen], + ); + + const readClipboard = useCallback(async () => { + const text = await navigator.clipboard.readText(); + return text; + }, []); + + return { + writeClipboard, + readClipboard, + }; +}; diff --git a/src/hooks/timeout/index.test.ts b/src/hooks/timeout/index.test.ts new file mode 100644 index 00000000..9483ba51 --- /dev/null +++ b/src/hooks/timeout/index.test.ts @@ -0,0 +1,34 @@ +import { renderHook } from '@testing-library/react'; +import { useTimeout } from '.'; + +describe('useTimeout', () => { + it('指定時間後に実行される', () => { + const fn = vi.fn(); + vi.useFakeTimers(); + + renderHook(() => useTimeout(fn, 1000)); + vi.advanceTimersByTime(1000); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('指定時間前に実行されない', () => { + const fn = vi.fn(); + vi.useFakeTimers(); + + renderHook(() => useTimeout(fn, 1000)); + vi.advanceTimersByTime(10); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('指定時間前にアンマウントされない場合は実行されない', () => { + const fn = vi.fn(); + vi.useFakeTimers(); + + const { unmount } = renderHook(() => useTimeout(fn, 1000)); + unmount(); + + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/timeout/index.ts b/src/hooks/timeout/index.ts new file mode 100644 index 00000000..139b2bb6 --- /dev/null +++ b/src/hooks/timeout/index.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +export const useTimeout = ( + callback: () => void, + delay: number, +): void => { + useEffect(() => { + let timeoutId: number | null = null; + + timeoutId = window.setTimeout(() => { + callback(); + }, delay); + + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + }; + }, [callback, delay]); +}; diff --git a/src/providers/app.tsx b/src/providers/app.tsx new file mode 100644 index 00000000..1d9f6f6c --- /dev/null +++ b/src/providers/app.tsx @@ -0,0 +1,6 @@ +import { ToastProvider } from '@/components/toast'; +import { FC, PropsWithChildren } from 'react'; + +export const AppProvider: FC = ({ children }) => { + return {children}; +}; diff --git a/src/utils/uuid-v4/index.ts b/src/utils/uuid-v4/index.ts new file mode 100644 index 00000000..9e4db606 --- /dev/null +++ b/src/utils/uuid-v4/index.ts @@ -0,0 +1,40 @@ +export const uuidV4 = (): string => { + if (isSecureContext) { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + (c) => { + const randHex = Math.floor(Math.random() * 16); + if (c === 'y') { + return ((randHex & 0x3) | 0x8).toString(16); + } + return randHex.toString(16); + }, + ); +}; + +if (import.meta.vitest) { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + it('安全なコンテキストではrandUUIDからuuidv4を返す', () => { + vi.stubGlobal('isSecureContext', true); + const excepted = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + const result = uuidV4(); + + expect(result).toMatch(excepted); + }); + + it('安全なコンテキスト外では自作のuuidv4を返す', () => { + vi.stubGlobal('isSecureContext', false); + const excepted = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + const result = uuidV4(); + + expect(result).toMatch(excepted); + }); +}