diff --git a/.claude/rules/common/duplication.md b/.claude/rules/common/duplication.md index 325f19ed..0e7f33c4 100644 --- a/.claude/rules/common/duplication.md +++ b/.claude/rules/common/duplication.md @@ -63,7 +63,7 @@ CLAUDE.md にある通り「PEP8 を守るな、PEP8 を理解した上で抽象 2. **純粋関数 / 文字列変換 / 日付処理** → `frontend/src/utils/` 3. **API クライアントの共通パターン** → `frontend/src/api/client.ts` のラッパー追加 4. **フォーム入出力変換** → `frontend/src/formMappers.ts` / `frontend/src/payloadBuilders.ts` -5. **共通 UI コンポーネント** → `frontend/src/components/ui/`(ErrorToast, Skeleton 等の配置例) +5. **共通 UI コンポーネント** → `frontend/src/components/ui/`(toast/, Skeleton 等の配置例) 6. **型定義** → `frontend/src/types.ts` / `frontend/src/formTypes.ts` ### Infra (OpenTofu) diff --git a/.claude/rules/frontend/architecture.md b/.claude/rules/frontend/architecture.md index b772a46a..1cf4e7f4 100644 --- a/.claude/rules/frontend/architecture.md +++ b/.claude/rules/frontend/architecture.md @@ -32,7 +32,7 @@ frontend/src/ │ ├── auth/ # LoginForm, RegisterForm │ ├── blog/ # BlogPage │ ├── icons/ # アイコンコンポーネント(Bell, Eye, Qiita, Zenn 等) -│ └── ui/ # 汎用 UI(ErrorToast, InlineSpinner, Skeleton, AsyncTaskLoading) +│ └── ui/ # 汎用 UI(toast/(ToastProvider/useToast), InlineSpinner, Skeleton, AsyncTaskLoading) ├── hooks/ │ ├── useDocumentForm.ts # フォーム CRUD の共通フック(loading / saving / error 管理) │ ├── useMasterData.ts # マスタデータのモジュールレベルキャッシュ diff --git a/docs/adr/0009-frontend-toast-notification.md b/docs/adr/0009-frontend-toast-notification.md new file mode 100644 index 00000000..c8b77cf6 --- /dev/null +++ b/docs/adr/0009-frontend-toast-notification.md @@ -0,0 +1,69 @@ +# ADR-0009: フロントエンドの一時通知をトースト方式に統一する + +## ステータス + +Accepted + +## コンテキスト + +職務経歴書(Resume)・ブログ連携・ログイン・GitHub 連携の各画面では、操作の成功/失敗メッセージを +`{error &&

...}` / `{success &&

...}` のようにフォーム内へインライン表示していた。 + +この方式には次の課題があった。 + +- メッセージがレイアウト内に流し込まれるため、スクロール位置によっては見えない・気付かれない。 +- 表示位置・スタイルが画面ごとにバラバラ(`shared.error` / `BlogPage.module.css` / `LoginForm.module.css` 等)。 +- 成功と失敗で消える/残るの挙動を制御できず、成功メッセージが残り続ける。 +- GitHub 連携だけは `ErrorToast` コンポーネントでカード表示していたが、他画面と統一されていなかった。 + +操作フィードバックを画面横断で一貫した「トースト(画面隅に一時表示する通知)」に統一したい。 + +## 決定内容 + +- 外部ライブラリを導入せず、**自前の Toast 基盤**を `frontend/src/components/ui/toast/` に実装する。 + - `ToastProvider`(Context + スタック state)を `main.tsx` の最上位(全ルートを覆う位置)に 1 つ設置。 + - `useToast()` が `showSuccess(message)` / `showError(string | AppErrorState)` / `dismiss(id)` を提供。 + - `ToastViewport` は `createPortal` で body 直下に固定表示し、各ページのスタッキングコンテキスト + (`LoadingOverlay` 等)に埋もれないようにする。 +- **挙動**: 成功トーストは一定時間(`SUCCESS_TOAST_DURATION_MS`)で自動消去、エラートーストは + 自動消去せず × ボタンで手動クローズする。 +- **既存フックは変更しない**。`useDocumentForm` / `usePdfActions` / `useBlogAccountManager` / + `useAsyncTaskPage` は従来通り error/success を state として保持し、表示層の薄いブリッジ + (`useMessageToast` / `useAppErrorToast`)でトーストへ橋渡しする。これによりフックを Provider に + 依存させず、既存のユニットテストをそのまま維持する。 +- 旧 `ErrorToast` コンポーネントは `ToastItem` のエラー表示(`AppErrorState` の回復アクション・ + エラー ID 表示を含む)へ統合し、削除する。 +- **適用範囲**: 「ページ全体の成功/失敗」のみをトースト化する。項目バリデーション(保存前の + 入力チェック)とファイル取り込み補助パネルのエラーは、フォーカス・赤枠・パネル状態と密結合した + 文脈情報のため従来通りインライン表示を維持する。 + +## 代替案 + +- **トーストライブラリ(sonner / react-hot-toast)導入**: Provider・自動消去・スタックが即利用できるが、 + 依存とバンドルサイズが増え、既存の `ErrorToast` / `ERROR_CONFIG`(回復アクション)資産との橋渡しが + 別途必要になる。一時通知のためだけに依存を増やす利得が小さいと判断し却下。 +- **フック内部から直接トーストを発火**(`useToast` をフックが呼ぶ): 表示と state の二重持ちを解消できるが、 + 対象フックすべてが ToastProvider 必須となり、既存ユニットテスト(error/success を assert)を + 全面的に書き換える必要がある。契約変更の影響が大きいため、表示層ブリッジ方式を採用した。 + +## トレードオフ・既知のリスク + +- 表示層ブリッジ(`useMessageToast`)は state の文字列変化を `useEffect` で監視する方式のため、 + StrictMode の effect 二重実行や同一文言の連続表示を `ref` でガードしている。この前提が崩れると + 二重表示や表示漏れの恐れがあるため、ガードはテストで固定する。 +- フックは「表示されない error/success 文字列」を保持し続ける(presentation はブリッジが読む)。 + state の責務が二段になる点は許容する。 +- トーストは画面隅に出るため、長文メッセージや同時多発の通知ではスタックが縦に伸びる。現状は + 自動消去(成功)と手動クローズ(エラー)で許容範囲とする。 + +## 将来の移行条件 + +- 通知の同時多発・グルーピング・アクション付き通知(Undo 等)の要件が増えた場合は、専用ライブラリ + (sonner 等)への移行を再検討する。 +- 通知の永続化(既読管理)が必要になった場合は `NotificationBell` 系の仕組みと統合を検討する。 + +## 関連リンク + +- 実装: `frontend/src/components/ui/toast/` +- メッセージ SSoT: `frontend/src/constants/messages.ts`、ルール `.claude/rules/frontend/messages.md` +- エラーコード連携: `frontend/src/constants/errorMessages.ts`(`ERROR_CONFIG`) diff --git a/frontend/src/components/auth/LoginForm.module.css b/frontend/src/components/auth/LoginForm.module.css index 33d6ba73..485d1094 100644 --- a/frontend/src/components/auth/LoginForm.module.css +++ b/frontend/src/components/auth/LoginForm.module.css @@ -71,13 +71,3 @@ font-size: 1rem; color: var(--text-secondary); } - -.errorMessage { - margin: 0; - color: var(--danger-text); - background: var(--danger-bg); - border: 1px solid var(--danger-border); - border-radius: 6px; - padding: 0.75rem 1rem; - font-size: 0.9rem; -} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 2b3e6eb7..5f62a031 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { initiateGitHubLogin } from "../../api"; +import { useMessageToast } from "../ui/toast"; import shared from "../../styles/shared.module.css"; import styles from "./LoginForm.module.css"; @@ -11,6 +12,9 @@ export function LoginForm({ }) { const [isLoading, setIsLoading] = useState(false); + // OAuth 失敗などのログインエラーをトーストで通知する(手動クローズ)。 + useMessageToast(githubError, "error"); + const handleGitHubLogin = async () => { setIsLoading(true); try { @@ -31,7 +35,6 @@ export function LoginForm({ ) : (

DevForge

- {githubError &&

{githubError}

}
- {accountError &&

{accountError}

} - {success &&

{success}

} - (null); + const { items: techStackOptions, loading: techLoading } = useTechnologyStacks(); const { items: qualificationOptions, loading: qualLoading } = useQualifications(); const qualificationNames = qualificationOptions.map((item) => item.name); @@ -105,9 +111,14 @@ export function CareerResumeForm() { getPdfBlobUrl: getCareerResumePdfBlobUrl, }); - /** PDF アクションまたはフォーム保存のエラー・成功メッセージを統合して表示する */ - const error = pdfError ?? formError ?? null; - const success = pdfSuccess ?? formSuccess; + // PDF アクションとフォーム保存/削除の成否を、チャンネルごとに独立してトーストで通知する。 + // pdf と form を `??` で統合すると、片方の値が残っている間にもう片方が更新されても + // 統合値が変化せずトーストが出ないため、それぞれ個別に橋渡しする。 + // 成功は自動消去、失敗は手動クローズ(ブリッジ内で variant 別に制御)。 + useMessageToast(formSuccess, "success"); + useMessageToast(formError, "error"); + useMessageToast(pdfSuccess, "success"); + useMessageToast(pdfError, "error"); /** Skeleton 表示・入力ロックの統合フラグ */ const formLocked = loading; @@ -130,6 +141,7 @@ export function CareerResumeForm() { const setFormAndClearFocus = useCallback>>( (action) => { setFocusTarget(null); + setValidationError(null); setForm(action); }, [setForm], @@ -137,6 +149,7 @@ export function CareerResumeForm() { const onChangeField = (key: CareerTextFieldKey, value: string) => { setFocusTarget(null); + setValidationError(null); setForm((prev) => ({ ...prev, [key]: value })); }; @@ -145,12 +158,12 @@ export function CareerResumeForm() { // 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。 const validation = validateCareerForm(form); if (validation) { - setError(validation.message); - setSuccess(null); + setValidationError(validation.message); focusNonceRef.current += 1; setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current }); return; } + setValidationError(null); setFocusTarget(null); // 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。 if (changes.length === 0) { @@ -221,7 +234,7 @@ export function CareerResumeForm() { - )} - {action &&

{action}

} -
- )} - {errorId &&

エラーID: {errorId}

} -
- ); -} diff --git a/frontend/src/components/ui/toast/ToastItem.tsx b/frontend/src/components/ui/toast/ToastItem.tsx new file mode 100644 index 00000000..f61da700 --- /dev/null +++ b/frontend/src/components/ui/toast/ToastItem.tsx @@ -0,0 +1,75 @@ +import { useEffect } from "react"; + +import { isErrorCode } from "../../../constants/errorCodes"; +import { ERROR_CONFIG } from "../../../constants/errorMessages"; +import { UI_MESSAGES } from "../../../constants/messages"; +import { SUCCESS_TOAST_DURATION_MS, type ToastData } from "./toastContext"; +import styles from "./toast.module.css"; + +type Props = { + toast: ToastData; + onDismiss: (id: string) => void; +}; + +/** + * 単一トーストのカード。 + * + * - success: {@link SUCCESS_TOAST_DURATION_MS} 経過で自動消去(× ボタンでも閉じられる)。 + * - error: 自動消去せず × ボタンで手動クローズする。AppErrorState 由来(code あり)の場合は + * ERROR_CONFIG の回復アクション・補足アクション文言・エラー ID も表示する。 + */ +export function ToastItem({ toast, onDismiss }: Props) { + const { id, variant, message, code, action, errorId } = toast; + + // 成功トーストのみ一定時間で自動消去する。 + useEffect(() => { + if (variant !== "success") return; + const timer = window.setTimeout(() => onDismiss(id), SUCCESS_TOAST_DURATION_MS); + return () => window.clearTimeout(timer); + }, [variant, id, onDismiss]); + + // 構造化エラー(code あり)のときだけ回復アクションを解決する。 + // 文字列エラー(frontend 完結メッセージ)には回復ボタンを出さない。 + const recovery = code + ? (isErrorCode(code) ? ERROR_CONFIG[code] : ERROR_CONFIG.INTERNAL_ERROR).recovery + : null; + + return ( +
+
+

{message}

+ {(recovery || action) && ( +
+ {recovery && ( + + )} + {action &&

{action}

} +
+ )} + {errorId && ( +

+ {UI_MESSAGES.TOAST_ERROR_ID_LABEL} {errorId} +

+ )} +
+ +
+ ); +} diff --git a/frontend/src/components/ui/toast/ToastProvider.test.tsx b/frontend/src/components/ui/toast/ToastProvider.test.tsx new file mode 100644 index 00000000..e65f0037 --- /dev/null +++ b/frontend/src/components/ui/toast/ToastProvider.test.tsx @@ -0,0 +1,230 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { describe, it, expect, vi } from "vitest"; + +import { UI_MESSAGES } from "../../../constants/messages"; +import type { AppErrorState } from "../../../utils/appError"; +import { ToastProvider } from "./ToastProvider"; +import { SUCCESS_TOAST_DURATION_MS, useToast } from "./toastContext"; +import { useAppErrorToast, useMessageToast } from "./useToastBridge"; + +/** showSuccess / showError を任意のラベルから発火させるトリガーボタン。 */ +function Trigger({ onClick, label }: { onClick: () => void; label: string }) { + return ( + + ); +} + +/** useToast を呼び出してトリガーを描画するハーネス。 */ +function ToastHarness({ render: renderTrigger }: { render: (api: ReturnType) => ReactElement }) { + const api = useToast(); + return renderTrigger(api); +} + +describe("ToastProvider / useToast", () => { + it("showSuccess は成功トーストを表示し、一定時間で自動消去される", () => { + vi.useFakeTimers(); + try { + render( + + ( + showSuccess("保存しました")} /> + )} + /> + , + ); + + fireEvent.click(screen.getByText("出す")); + expect(screen.getByText("保存しました")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(SUCCESS_TOAST_DURATION_MS); + }); + expect(screen.queryByText("保存しました")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("showError(文字列) はエラートーストを表示し、自動消去されず手動で閉じられる", () => { + vi.useFakeTimers(); + try { + render( + + ( + showError("保存に失敗しました")} /> + )} + /> + , + ); + + fireEvent.click(screen.getByText("出す")); + expect(screen.getByText("保存に失敗しました")).toBeInTheDocument(); + + // 自動消去時間を大きく超えても残り続ける(エラーは手動クローズ)。 + act(() => { + vi.advanceTimersByTime(SUCCESS_TOAST_DURATION_MS * 3); + }); + expect(screen.getByText("保存に失敗しました")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: UI_MESSAGES.TOAST_DISMISS })); + expect(screen.queryByText("保存に失敗しました")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("showError(AppErrorState) はメッセージと回復アクションボタンを表示する", () => { + const appError: AppErrorState = { + code: "AUTH_EXPIRED", + message: "セッションが切れました", + action: "再度ログインしてください", + retryAfter: null, + errorId: "err-auth-1", + }; + render( + + ( + showError(appError)} /> + )} + /> + , + ); + + fireEvent.click(screen.getByText("出す")); + expect(screen.getByText("セッションが切れました")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "ログインし直す" })).toBeInTheDocument(); + expect(screen.getByText(/err-auth-1/)).toBeInTheDocument(); + }); +}); + +/** message を prop で受け取り useMessageToast に橋渡しするハーネス。 */ +function MessageBridgeHarness({ message }: { message: string | null }) { + useMessageToast(message, "error"); + return null; +} + +describe("useMessageToast", () => { + it("message が非 null になった時だけ表示し、同一文言の重複表示を防ぐ", () => { + const { rerender } = render( + + + , + ); + expect(screen.queryByText("失敗しました")).not.toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.getAllByText("失敗しました")).toHaveLength(1); + + // 同じ message のまま再レンダリングしても増えない。 + rerender( + + + , + ); + expect(screen.getAllByText("失敗しました")).toHaveLength(1); + }); + + it("message が null に戻った後に同じ文言が再出現したら再表示する", () => { + const { rerender } = render( + + + , + ); + expect(screen.getAllByText("失敗しました")).toHaveLength(1); + + rerender( + + + , + ); + rerender( + + + , + ); + // 先のトーストは手動クローズしていないため、再表示で 2 件になる。 + expect(screen.getAllByText("失敗しました")).toHaveLength(2); + }); +}); + +/** error を prop で受け取り useAppErrorToast に橋渡しするハーネス。 */ +function AppErrorBridgeHarness({ error }: { error: AppErrorState | null }) { + useAppErrorToast(error); + return null; +} + +describe("useAppErrorToast", () => { + const makeError = (errorId: string): AppErrorState => ({ + code: "INTERNAL_ERROR", + message: "連携に失敗しました", + action: null, + retryAfter: null, + errorId, + }); + + it("errorId が変わった時だけ表示し、同一エラーの重複表示を防ぐ", () => { + const { rerender } = render( + + + , + ); + expect(screen.queryByText("連携に失敗しました")).not.toBeInTheDocument(); + + const first = makeError("e1"); + rerender( + + + , + ); + expect(screen.getAllByText("連携に失敗しました")).toHaveLength(1); + + // 同一 errorId の参照変更では増えない。 + rerender( + + + , + ); + expect(screen.getAllByText("連携に失敗しました")).toHaveLength(1); + + // 新しい errorId では新規表示。 + rerender( + + + , + ); + expect(screen.getAllByText("連携に失敗しました")).toHaveLength(2); + }); + + it("errorId が空の場合は重複判定せず毎回表示する", () => { + const { rerender } = render( + + + , + ); + + // 空 errorId のエラーは重複判定の基準にできないため、別インスタンスごとに表示される。 + rerender( + + + , + ); + expect(screen.getAllByText("連携に失敗しました")).toHaveLength(1); + + rerender( + + + , + ); + expect(screen.getAllByText("連携に失敗しました")).toHaveLength(2); + }); +}); diff --git a/frontend/src/components/ui/toast/ToastProvider.tsx b/frontend/src/components/ui/toast/ToastProvider.tsx new file mode 100644 index 00000000..3303db5d --- /dev/null +++ b/frontend/src/components/ui/toast/ToastProvider.tsx @@ -0,0 +1,69 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +import type { AppErrorState } from "../../../utils/appError"; +import { + ToastContext, + type ToastContextValue, + type ToastData, +} from "./toastContext"; +import { ToastViewport } from "./ToastViewport"; + +/** + * アプリ全体のトースト表示を司る Provider。 + * + * 成功/エラーの一時通知をスタック管理し、{@link ToastViewport} を body 直下のポータルに描画する。 + * 各画面のフックが従来通り保持する error/success state は、表示層の {@link useMessageToast} / + * {@link useAppErrorToast} ブリッジ経由でこの Provider に橋渡しされる。 + */ +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + // トースト ID の単調増加カウンタ。同一ミリ秒の連続発行でも衝突しないようにする。 + const seqRef = useRef(0); + + const nextId = useCallback(() => { + seqRef.current += 1; + return `toast-${seqRef.current}`; + }, []); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const showSuccess = useCallback( + (message: string) => { + setToasts((prev) => [...prev, { id: nextId(), variant: "success", message }]); + }, + [nextId], + ); + + const showError = useCallback( + (error: string | AppErrorState) => { + const toast: ToastData = + typeof error === "string" + ? { id: nextId(), variant: "error", message: error } + : { + id: nextId(), + variant: "error", + message: error.message, + code: error.code, + action: error.action, + errorId: error.errorId, + }; + setToasts((prev) => [...prev, toast]); + }, + [nextId], + ); + + const value = useMemo( + () => ({ showSuccess, showError, dismiss }), + [showSuccess, showError, dismiss], + ); + + return ( + + {children} + + + ); +} diff --git a/frontend/src/components/ui/toast/ToastViewport.tsx b/frontend/src/components/ui/toast/ToastViewport.tsx new file mode 100644 index 00000000..5e6e7921 --- /dev/null +++ b/frontend/src/components/ui/toast/ToastViewport.tsx @@ -0,0 +1,34 @@ +import { createPortal } from "react-dom"; + +import { UI_MESSAGES } from "../../../constants/messages"; +import type { ToastData } from "./toastContext"; +import { ToastItem } from "./ToastItem"; +import styles from "./toast.module.css"; + +type Props = { + toasts: ToastData[]; + onDismiss: (id: string) => void; +}; + +/** + * トーストのスタックを画面右上に固定表示するコンテナ。 + * + * body 直下にポータルで描画し、各ページのレイアウトや LoadingOverlay 等の + * スタッキングコンテキストに埋もれないようにする。 + */ +export function ToastViewport({ toasts, onDismiss }: Props) { + if (toasts.length === 0) return null; + + return createPortal( +
+ {toasts.map((toast) => ( + + ))} +
, + document.body, + ); +} diff --git a/frontend/src/components/ui/toast/index.ts b/frontend/src/components/ui/toast/index.ts new file mode 100644 index 00000000..dcd26d30 --- /dev/null +++ b/frontend/src/components/ui/toast/index.ts @@ -0,0 +1,3 @@ +export { ToastProvider } from "./ToastProvider"; +export { useToast, type ToastVariant, type ToastData } from "./toastContext"; +export { useMessageToast, useAppErrorToast } from "./useToastBridge"; diff --git a/frontend/src/components/ui/toast/toast.module.css b/frontend/src/components/ui/toast/toast.module.css new file mode 100644 index 00000000..ee754d15 --- /dev/null +++ b/frontend/src/components/ui/toast/toast.module.css @@ -0,0 +1,109 @@ +.viewport { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.6rem; + width: min(420px, calc(100vw - 2rem)); + /* コンテナ自体はクリックを透過し、各トーストのみ操作可能にする。 */ + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 0.6rem; + padding: 0.85rem 1rem; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-primary); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + animation: toast-in 0.18s ease-out; +} + +.success { + /* 正常系の背景はティントを付けず、テーマの背景色(ライト/ダーク)をそのまま使う。 + success である識別は枠線の緑ティントで担う。 */ + border-color: color-mix(in srgb, var(--success) 32%, var(--border)); + background: var(--bg-card); +} + +.error { + border-color: color-mix(in srgb, var(--error) 32%, var(--border)); + background: color-mix(in srgb, var(--error) 8%, var(--bg-card)); +} + +.body { + display: grid; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.message { + margin: 0; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.actions { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + align-items: center; +} + +.actionButton { + width: fit-content; +} + +.actionText { + margin: 0; + color: var(--text-muted); + font-size: 0.85rem; +} + +.errorId { + margin: 0; + color: var(--text-muted); + font-size: 0.8rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.dismiss { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-muted); + font-size: 1.25rem; + line-height: 1; + padding: 0 0.2rem; + cursor: pointer; +} + +.dismiss:hover { + color: var(--text-primary); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 640px) { + .viewport { + left: 1rem; + right: 1rem; + width: auto; + } +} diff --git a/frontend/src/components/ui/toast/toastContext.ts b/frontend/src/components/ui/toast/toastContext.ts new file mode 100644 index 00000000..a8da48db --- /dev/null +++ b/frontend/src/components/ui/toast/toastContext.ts @@ -0,0 +1,52 @@ +import { createContext, useContext } from "react"; + +import type { AppErrorState } from "../../../utils/appError"; + +/** トーストの種別。success は自動消去、error は手動クローズ。 */ +export type ToastVariant = "success" | "error"; + +/** 表示中のトースト 1 件分のデータ。 */ +export type ToastData = { + id: string; + variant: ToastVariant; + message: string; + /** + * 構造化エラー({@link AppErrorState})由来の場合のみ設定される。 + * ERROR_CONFIG の回復アクション解決に使う。 + */ + code?: string; + /** API が返した補足アクション文言(AppErrorState 由来)。 */ + action?: string | null; + /** エラー追跡用の ID(AppErrorState 由来)。 */ + errorId?: string; +}; + +/** {@link useToast} が返すトースト操作 API。 */ +export type ToastContextValue = { + /** 成功トーストを表示する(一定時間で自動消去される)。 */ + showSuccess: (message: string) => void; + /** + * エラートーストを表示する(手動で閉じるまで残る)。 + * 文字列(frontend 完結のメッセージ)と {@link AppErrorState}(API 由来)の両方を受け付ける。 + */ + showError: (error: string | AppErrorState) => void; + /** 指定 ID のトーストを閉じる。 */ + dismiss: (id: string) => void; +}; + +/** 成功トーストが自動消去されるまでの時間(ms)。 */ +export const SUCCESS_TOAST_DURATION_MS = 4000; + +export const ToastContext = createContext(null); + +/** + * トースト表示 API を取得するフック。{@link ToastProvider} 配下でのみ利用できる。 + */ +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + // 開発者向け内部エラー(英語): Provider 外で呼ばれた配線ミスを握りつぶさず即座に気付けるようにする。 + throw new Error("useToast must be used within a ToastProvider"); + } + return ctx; +} diff --git a/frontend/src/components/ui/toast/useToastBridge.ts b/frontend/src/components/ui/toast/useToastBridge.ts new file mode 100644 index 00000000..6493d698 --- /dev/null +++ b/frontend/src/components/ui/toast/useToastBridge.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; + +import type { AppErrorState } from "../../../utils/appError"; +import { useToast, type ToastVariant } from "./toastContext"; + +/** + * 文字列メッセージの state を監視し、非 null になるたびトーストを出す表示層ブリッジ。 + * + * 各フック(useDocumentForm / usePdfActions / useBlogAccountManager 等)は従来通り + * error / success 文字列を保持し続ける。本ブリッジはそれをトーストへ橋渡しするだけの薄いアダプタで、 + * フック側を Provider に依存させずユニットテスト可能なまま保つ。 + * + * - React StrictMode による effect 二重実行や、同一文言が連続するケースでの二重表示を ref でガードする。 + * - message が空(null / undefined / 空文字)に戻ったら ref をリセットし、 + * 同じ文言が再び現れたときに再表示できるようにする。 + */ +export function useMessageToast( + message: string | null | undefined, + variant: ToastVariant, +): void { + const { showSuccess, showError } = useToast(); + const lastShownRef = useRef(null); + + useEffect(() => { + if (!message) { + lastShownRef.current = null; + return; + } + if (message === lastShownRef.current) return; + lastShownRef.current = message; + if (variant === "success") { + showSuccess(message); + } else { + showError(message); + } + }, [message, variant, showSuccess, showError]); +} + +/** + * {@link AppErrorState} の state を監視し、新しいエラーが現れるたびエラートーストを出すブリッジ。 + * 同一エラーの二重表示は errorId で防ぐ。useAsyncTaskPage のような構造化エラーを扱うページで使う。 + */ +export function useAppErrorToast(error: AppErrorState | null | undefined): void { + const { showError } = useToast(); + const lastErrorIdRef = useRef(null); + + useEffect(() => { + if (!error) { + lastErrorIdRef.current = null; + return; + } + // errorId は AppErrorState では必須だが、万一空の場合は重複判定の基準にできないため毎回表示する + // (空文字どうしが `===` で一致して別エラーを取りこぼすのを防ぐ防御)。 + if (!error.errorId) { + showError(error); + return; + } + if (error.errorId === lastErrorIdRef.current) return; + lastErrorIdRef.current = error.errorId; + showError(error); + }, [error, showError]); +} diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 5077d864..159e2a0b 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -74,6 +74,8 @@ export const SUCCESS_MESSAGES = { BLOG_LINKED: "アカウントを連携しました", BLOG_UNLINKED: "アカウントを解除しました", BLOG_USERNAME_UPDATED: "usernameを更新しました。再同期してください。", + CAREER_SAVED: "職務経歴書を保存しました。PDF出力できます。", + CAREER_PDF_DOWNLOADED: "職務経歴書PDFをダウンロードしました。", } as const; /** JSX に直書きされていた UI 文言(ErrorBoundary など) */ @@ -87,6 +89,10 @@ export const UI_MESSAGES = { SIDEBAR_EXPAND: "サイドバーを開く", SOURCE_PANEL_COLLAPSE: "原本パネルを閉じる", SOURCE_PANEL_EXPAND: "原本パネルを開く", + // トースト通知(右上に表示する成功/エラーの一時通知) + TOAST_REGION_LABEL: "通知", + TOAST_DISMISS: "通知を閉じる", + TOAST_ERROR_ID_LABEL: "エラーID:", REPORT_ISSUE: "GitHub Issueに報告", OPENS_IN_NEW_TAB: "(新しいタブで開きます)", COPYRIGHT: "© 2026 DevForge", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 75ae14a1..adde2393 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import { PersistGate } from "redux-persist/integration/react"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import { ToastProvider } from "./components/ui/toast"; import { persistor, store } from "./store"; import "./styles.css"; @@ -13,7 +14,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + + + diff --git a/frontend/src/styles/shared.module.css b/frontend/src/styles/shared.module.css index d9b957f7..c006873f 100644 --- a/frontend/src/styles/shared.module.css +++ b/frontend/src/styles/shared.module.css @@ -37,12 +37,6 @@ font-weight: 600; } -.success { - margin: 0; - color: var(--success); - font-weight: 600; -} - .entry { border: 1px dashed var(--border-dashed); border-radius: 10px; diff --git a/frontend/src/test/renderWithProviders.tsx b/frontend/src/test/renderWithProviders.tsx index 62d2368d..29be52ff 100644 --- a/frontend/src/test/renderWithProviders.tsx +++ b/frontend/src/test/renderWithProviders.tsx @@ -1,6 +1,6 @@ /** * テスト用レンダリングヘルパー。 - * Redux Provider と MemoryRouter をラップして提供する。 + * Redux Provider・MemoryRouter・ToastProvider をラップして提供する。 */ import { type ReactElement } from "react"; import { render, type RenderOptions } from "@testing-library/react"; @@ -8,6 +8,7 @@ import { Provider } from "react-redux"; import { MemoryRouter, type MemoryRouterProps } from "react-router-dom"; import { configureStore } from "@reduxjs/toolkit"; import formCacheReducer from "../store/formCacheSlice"; +import { ToastProvider } from "../components/ui/toast"; interface Options extends Omit { /** 文字列パス、または state を含む location オブジェクトを渡せる */ @@ -25,7 +26,9 @@ export function renderWithProviders( function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); }