Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions components/WalletBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// 未接続時: 「接続」ボタン + ▾ で開く dropdown (利用可能 connector 一覧)。
// dropdown は native <details> で実装し、JS state を最小化する。

import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { Check, ChevronDown } from 'lucide-react';
Expand Down Expand Up @@ -48,8 +49,27 @@ export function WalletBadge() {
const handleSignIn = () => {
void signIn(t('siweStatement')).catch(() => undefined);
};
// メニュー内の操作 (接続 / 切断) で branch が入れ替わると、押したボタンごと <details> が作り直され
// フォーカスが body へ落ちる。メニューから操作したときだけ新しい summary へ戻す
// (ページ読み込み時の自動再接続ではフォーカスを奪わない)。
const summaryRef = useRef<HTMLElement | null>(null);
const restoreFocusRef = useRef(false);
useEffect(() => {
if (!restoreFocusRef.current) return;
restoreFocusRef.current = false;
// 落ちたフォーカスを拾うだけ: ウォレットの承認待ちの間に利用者が別の場所へ移していたら奪わない。
const active = document.activeElement;
if (active && active !== document.body) return;
summaryRef.current?.focus();
}, [isConnected]);
// 接続が失敗・拒否で終わったら予約を捨てる (後で別の場所から接続したときにフォーカスを奪わない)。
useEffect(() => {
if (error) restoreFocusRef.current = false;
}, [error]);

// 切断時はセッション cookie も破棄して「ログイン済だが未接続」の宙ぶらりんを残さない。
const handleDisconnect = () => {
restoreFocusRef.current = true;
void signOut().catch(() => undefined);
disconnect();
};
Expand All @@ -59,7 +79,7 @@ export function WalletBadge() {
// key: 未接続 branch と同じ位置の <details> なので、無いと React が DOM を使い回し open が
// 引き継がれる (接続直後にメニューが開いたまま本文へ被さる)。
<details key="connected" className="group relative">
<summary className="flex cursor-pointer list-none items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">
<summary ref={summaryRef} className="flex cursor-pointer list-none items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" aria-hidden />
{siweEnabled && isSignedIn && (
<Check className="h-3 w-3 text-emerald-600" aria-label={t('signedIn')} />
Expand Down Expand Up @@ -127,7 +147,7 @@ export function WalletBadge() {

return (
<details key="disconnected" className="group relative">
<summary className="flex cursor-pointer list-none items-center gap-1 rounded-full bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-dark">
<summary ref={summaryRef} className="flex cursor-pointer list-none items-center gap-1 rounded-full bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-dark">
{t('connect')}
<ChevronDown
className="h-3 w-3 transition-transform group-open:rotate-180"
Expand All @@ -147,7 +167,10 @@ export function WalletBadge() {
type="button"
role="menuitem"
disabled={isPending}
onClick={() => connect({ connector: c })}
onClick={() => {
restoreFocusRef.current = true;
connect({ connector: c });
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-slate-700 hover:bg-slate-100 disabled:opacity-50"
>
{/* ウォレットアイコン (EIP-6963 data URI or 同梱 SVG)。装飾なので alt は空。 */}
Expand Down
128 changes: 117 additions & 11 deletions tests/components/WalletBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, screen, within } from '@testing-library/react';
import { renderWithIntl } from '../_helpers/i18n';
Expand Down Expand Up @@ -30,12 +32,13 @@ vi.mock('@/hooks/useSiweSession', () => ({
// (既定 = freee ON で SIWE UI を出す。両 OFF で隠れることは専用 test で検証)。
const flags = vi.hoisted(() => ({
enableFreeeSync: true,
enableBilling: false,
enableUsageFee: false,
enablePro: false,
enableCsvPass: false,
enablePushNotify: false,
enableTipMessage: false,
enableCreatorStoreUi: false,
enableHandles: false,
}));
vi.mock('@/lib/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/env')>();
Expand All @@ -46,8 +49,8 @@ vi.mock('@/lib/env', async (importOriginal) => {
get enableFreeeSync() {
return flags.enableFreeeSync;
},
get enableBilling() {
return flags.enableBilling;
get enableUsageFee() {
return flags.enableUsageFee;
},
get enablePro() {
return flags.enablePro;
Expand All @@ -64,6 +67,9 @@ vi.mock('@/lib/env', async (importOriginal) => {
get enableCreatorStoreUi() {
return flags.enableCreatorStoreUi;
},
get enableHandles() {
return flags.enableHandles;
},
},
};
});
Expand Down Expand Up @@ -148,12 +154,13 @@ beforeEach(() => {
visibleConnectorsMock.mockReturnValue([]);
setSiwe();
flags.enableFreeeSync = true; // 既定: SIWE 機能 ON → ログイン UI を出す
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = false;
flags.enableCsvPass = false;
flags.enablePushNotify = false;
flags.enableTipMessage = false;
flags.enableCreatorStoreUi = false;
flags.enableHandles = false;
});

describe('WalletBadge: 接続済 branch', () => {
Expand Down Expand Up @@ -283,14 +290,15 @@ describe('WalletBadge: SIWE サインイン', () => {
expect(disconnect).toHaveBeenCalledTimes(1);
});

it('SIWE 機能 (freee/利用権/Pro/CSVパス/push通知/tip質問) が全 OFF → ログイン UI を出さない (切断のみ)', () => {
it('SIWE 機能の flag が全 OFF → ログイン UI を出さない (切断のみ)', () => {
flags.enableFreeeSync = false;
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = false;
flags.enableCsvPass = false;
flags.enablePushNotify = false;
flags.enableTipMessage = false;
flags.enableCreatorStoreUi = false;
flags.enableHandles = false;
setConnected();
setSiwe({ isSignedIn: false }); // 仮にサインインしていなくてもログイン導線を出さない
renderWithIntl(<WalletBadge />);
Expand All @@ -310,7 +318,7 @@ describe('WalletBadge: SIWE サインイン', () => {
// 回帰防止: siweEnabled に enablePushNotify を含めないと、push 単独構成でヘッダーから
// サインインできず PushNotifyPanel が不到達になる (CsvPassPaywall と同型の教訓)。
flags.enableFreeeSync = false;
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = false;
flags.enableCsvPass = false;
flags.enablePushNotify = true;
Expand All @@ -325,7 +333,7 @@ describe('WalletBadge: SIWE サインイン', () => {

it('チップ質問 inbox のみ ON → ログイン UI を出す (閲覧にサインインが要る)', () => {
flags.enableFreeeSync = false;
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = false;
flags.enableCsvPass = false;
flags.enablePushNotify = false;
Expand All @@ -341,7 +349,7 @@ describe('WalletBadge: SIWE サインイン', () => {

it('Creator Store UI のみ ON → 出品管理用のログイン UI を出す', () => {
flags.enableFreeeSync = false;
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = false;
flags.enableCsvPass = false;
flags.enablePushNotify = false;
Expand All @@ -360,7 +368,7 @@ describe('WalletBadge: SIWE サインイン', () => {
// 回帰防止: siweEnabled に enablePro を含めないと、Pro 単独構成でヘッダーからサインイン
// できず、Pro ゲート (ProPaywall) が不到達になる。Pro だけでもログイン導線を出す。
flags.enableFreeeSync = false;
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = true;
setConnected();
setSiwe({ isSignedIn: false });
Expand All @@ -375,7 +383,7 @@ describe('WalletBadge: SIWE サインイン', () => {
// 回帰防止: siweEnabled に enableCsvPass を含めないと、CSV パス単独構成 (= 現行の CSV ゲート)
// でヘッダーからサインインできず、CSV パスゲート (CsvPassPaywall) が不到達になる。
flags.enableFreeeSync = false;
flags.enableBilling = false;
flags.enableUsageFee = false;
flags.enablePro = false;
flags.enableCsvPass = true;
setConnected();
Expand Down Expand Up @@ -518,3 +526,101 @@ describe('WalletBadge: 接続状態の切り替わり', () => {
expect(container.querySelector('details')?.open).toBe(false);
});
});

describe('WalletBadge: 接続・切断後のフォーカス', () => {
it('メニューから接続 → 作り直された summary へフォーカスを戻す (body に落とさない)', () => {
setDisconnected();
visibleConnectorsMock.mockReturnValue([{ uid: '1', name: 'MetaMask' }]);
const { rerender } = renderWithIntl(<WalletBadge />);
const details = openDropdown('接続');
fireEvent.click(within(details).getByRole('menuitem', { name: 'MetaMask' }));

setConnected();
rerender(<WalletBadge />);
expect(document.activeElement).toBe(screen.getByText(/0x52d4/i).closest('summary'));
});

it('メニューから切断 → 「接続」の summary へフォーカスを戻す', () => {
setConnected();
const { rerender } = renderWithIntl(<WalletBadge />);
const details = openDropdown(/0x52d4/i);
fireEvent.click(within(details).getByRole('menuitem', { name: '切断' }));

setDisconnected();
rerender(<WalletBadge />);
expect(document.activeElement).toBe(screen.getByText('接続').closest('summary'));
});

it('承認待ちの間に利用者が別の場所へフォーカスを移していたら奪わない', () => {
setDisconnected();
visibleConnectorsMock.mockReturnValue([{ uid: '1', name: 'MetaMask' }]);
const other = document.createElement('input');
document.body.appendChild(other);
try {
const { rerender } = renderWithIntl(<WalletBadge />);
const details = openDropdown('接続');
fireEvent.click(within(details).getByRole('menuitem', { name: 'MetaMask' }));
other.focus();

setConnected();
rerender(<WalletBadge />);
expect(document.activeElement).toBe(other);
} finally {
other.remove();
}
});

it('メニューを使わない接続 (自動再接続・ページ側のボタン) ではフォーカスを奪わない', () => {
setDisconnected();
const { rerender } = renderWithIntl(<WalletBadge />);
setConnected();
rerender(<WalletBadge />);
expect(document.activeElement).toBe(document.body);
});

it('接続が拒否で終わったあと、別経路で接続してもフォーカスを奪わない', () => {
setDisconnected();
visibleConnectorsMock.mockReturnValue([{ uid: '1', name: 'MetaMask' }]);
const { rerender } = renderWithIntl(<WalletBadge />);
const details = openDropdown('接続');
fireEvent.click(within(details).getByRole('menuitem', { name: 'MetaMask' }));
setDisconnected({ error: new Error('User rejected the request') });
rerender(<WalletBadge />);

setConnected();
rerender(<WalletBadge />);
expect(document.activeElement).toBe(document.body);
});
});

describe('WalletBadge: siweEnabled の flag 網羅 (掟 7)', () => {
const FLAG_NAMES = Object.keys(flags) as (keyof typeof flags)[];

it('このテストの flag holder は WalletBadge の siweEnabled が読む env キーと一致する', () => {
// holder が実装とずれると、mock されない flag は実 env の false 固定になり、
// 「足し忘れるとヘッダからサインインできない」を守るテストが黙って効かなくなる
// (旧 holder は実在しない enableBilling を持ち、enableUsageFee / enableHandles を欠いていた)。
const src = readFileSync(join(process.cwd(), 'components/WalletBadge.tsx'), 'utf8');
const block = src.slice(src.indexOf('const siweEnabled ='), src.indexOf('const handleSignIn'));
const used = [...block.matchAll(/env\.(enable\w+)/g)].map((m) => m[1]).sort();
expect(used.length).toBeGreaterThan(0);
// 書き方も固定する: 分割代入や helper 経由で flag を足すと上の正規表現に掛からず、holder にも
// 無いまま「一致」して通ってしまう。ブロック内の enable* はすべて `env.` 直読みであること。
const bare = [...block.matchAll(/(?<!env\.)\benable\w+/g)].map((m) => m[0]);
expect(bare).toEqual([]);
// 下の it.each の網羅は holder 由来なので、このフェンスが通っていることが前提。
expect([...FLAG_NAMES].sort()).toEqual(used);
});

it.each(FLAG_NAMES)('%s のみ ON → ヘッダにログイン UI を出す', (name) => {
for (const k of FLAG_NAMES) flags[k] = false;
flags[name] = true;
setConnected();
setSiwe({ isSignedIn: false });
renderWithIntl(<WalletBadge />);
const details = openDropdown('0x52d4…cA81');
expect(
within(details).getByRole('menuitem', { name: 'ログイン (署名)' }),
).toBeInTheDocument();
});
});
Loading