diff --git a/components/CsvPassModal.tsx b/components/CsvPassModal.tsx index bd41d450..60474108 100644 --- a/components/CsvPassModal.tsx +++ b/components/CsvPassModal.tsx @@ -10,6 +10,7 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; import { X } from 'lucide-react'; +import { trapModalFocus } from '@/lib/trapModalFocus'; import { CsvPassPaywall } from './CsvPassPaywall'; export function CsvPassModal({ @@ -26,20 +27,22 @@ export function CsvPassModal({ onCloseRef.current = onClose; // 開く時だけ focus を移し、close / unmount 時に dialog 側に残っていれば元の要素へ戻す。 - // TODO(B-R11f): focus trap は別 PR で扱う。 useEffect(() => { if (!open) return; const previousFocus = document.activeElement; // cleanup 時は ref が detach 済み (StrictMode の擬似 unmount も含む) なので node を捕捉する。 const dialog = dialogRef.current; function onKey(e: KeyboardEvent) { + // IME のキャンセル操作で modal を閉じない。 + if (e.isComposing || e.keyCode === 229) return; if (e.key === 'Escape') onCloseRef.current(); + if (dialog) trapModalFocus(e, dialog); } window.addEventListener('keydown', onKey); dialog?.focus(); return () => { window.removeEventListener('keydown', onKey); - // trap が無いので、利用者が背後の入力欄などへ移した focus を opener へ奪わない。 + // 明示的に別の要素へ移された focus は opener へ奪わない。 // dialog 除去で body に落ちた場合と、StrictMode の擬似 cleanup で dialog 内に残る場合は戻す。 const active = document.activeElement; if ( diff --git a/components/QrGenerator.tsx b/components/QrGenerator.tsx index f4ffcf24..eb2fab33 100644 --- a/components/QrGenerator.tsx +++ b/components/QrGenerator.tsx @@ -78,8 +78,6 @@ export function QrGenerator() { const [step2Initialized, setStep2Initialized] = useState(false); // QR は即時表示せず「QRコードを表示する」ボタン → 全画面モーダルで提示。 const [qrModalOpen, setQrModalOpen] = useState(false); - // onClose は modal の focus effect の依存。残高更新や入力で再 focus させない。 - const closeQrModal = useCallback(() => setQrModalOpen(false), []); // 初回に QR モーダルを開いた「ピークモーメント」を latch。以降 A2HS hint を出す // (毎日この QR を使う店主に、ホーム画面への追加を提案する)。閉じても latch は保持。 const [hasOpenedQr, setHasOpenedQr] = useState(false); @@ -597,7 +595,7 @@ export function QrGenerator() { {payUrl && ( setQrModalOpen(false)} labels={{ title: t('qrModalTitle'), close: t('qrModalClose'), diff --git a/components/QrPreviewModal.tsx b/components/QrPreviewModal.tsx index 6a0e3d81..13e5036c 100644 --- a/components/QrPreviewModal.tsx +++ b/components/QrPreviewModal.tsx @@ -14,6 +14,7 @@ import { CircleCheck, Eye, Printer, ScanLine, X } from 'lucide-react'; import NextImage from 'next/image'; import { TokenLogo, ChainLogo } from '@/components/AssetLogo'; import { QR_CENTER_MARK, QR_CENTER_MARK_RATIO } from '@/lib/qrCenterMark'; +import { trapModalFocus } from '@/lib/trapModalFocus'; import type { TokenSymbol } from '@/lib/tokens'; import type { ChainSlug } from '@/lib/chains'; @@ -121,20 +122,22 @@ export function QrPreviewModal({ onCloseRef.current = onClose; // 開く時だけ focus を移し、close / unmount 時に dialog 側に残っていれば元の要素へ戻す。 - // TODO(B-R11f): focus trap は別 PR で扱う。 useEffect(() => { if (!open) return; const previousFocus = document.activeElement; // cleanup 時は ref が detach 済み (StrictMode の擬似 unmount も含む) なので node を捕捉する。 const dialog = dialogRef.current; function onKey(e: KeyboardEvent) { + // IME のキャンセル操作で modal を閉じない。 + if (e.isComposing || e.keyCode === 229) return; if (e.key === 'Escape') onCloseRef.current(); + if (dialog) trapModalFocus(e, dialog); } window.addEventListener('keydown', onKey); dialog?.focus(); return () => { window.removeEventListener('keydown', onKey); - // trap が無いので、利用者が背後の入力欄などへ移した focus を opener へ奪わない。 + // 明示的に別の要素へ移された focus は opener へ奪わない。 // dialog 除去で body に落ちた場合と、StrictMode の擬似 cleanup で dialog 内に残る場合は戻す。 const active = document.activeElement; if ( diff --git a/components/SuccessOverlay.tsx b/components/SuccessOverlay.tsx index 586f8942..d4fc9b1c 100644 --- a/components/SuccessOverlay.tsx +++ b/components/SuccessOverlay.tsx @@ -4,13 +4,14 @@ // 視認できるよう、決済成功時に画面全体を緑色で塗りつぶす full-screen overlay。 // PayPay の「ペイペイ!」緑画面相当。 -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { Volume2, VolumeX } from 'lucide-react'; import { pad } from '@/lib/pad'; import { isSuccessSoundEnabled } from '@/lib/soundPref'; import { playSuccessChime } from '@/lib/successChime'; +import { trapModalFocus } from '@/lib/trapModalFocus'; import { useSuccessSoundPref } from '@/hooks/useSuccessSoundPref'; import { CopyableField } from './CopyableField'; import { NonCustodialNotice } from './NonCustodialNotice'; @@ -45,8 +46,12 @@ export function SuccessOverlay({ onDismiss: () => void; }) { const t = useTranslations('SuccessOverlay'); + const titleId = useId(); const [now, setNow] = useState(() => new Date()); const dialogRef = useRef(null); + // inline onDismiss の更新で focus effect を再実行せず、ESC は最新のハンドラを読む。 + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; const [soundOn, setSoundOn] = useSuccessSoundPref(); // 決済完了チャイム (PayPay の「ペイペイ!」音 相当)。overlay マウント時に 1 回だけ @@ -73,15 +78,31 @@ export function SuccessOverlay({ if (next) playSuccessChime(); } - // ESC で dismiss + dialog に focus (a11y) + // mount が open に相当する。開く時だけ focus し、unmount 時に guard 付きで復元する。 useEffect(() => { + const previousFocus = document.activeElement; + // cleanup 時の ref detach (StrictMode を含む) に備えて node を捕捉する。 + const dialog = dialogRef.current; function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') onDismiss(); + // IME のキャンセル操作で overlay を閉じない。 + if (e.isComposing || e.keyCode === 229) return; + if (e.key === 'Escape') onDismissRef.current(); + if (dialog) trapModalFocus(e, dialog); } window.addEventListener('keydown', onKey); - dialogRef.current?.focus(); - return () => window.removeEventListener('keydown', onKey); - }, [onDismiss]); + dialog?.focus(); + return () => { + window.removeEventListener('keydown', onKey); + // 明示的に別の要素へ移された focus は奪わず、dialog 内か除去後の body からだけ戻す。 + const active = document.activeElement; + if ( + previousFocus instanceof HTMLElement && + (!active || active === document.body || dialog?.contains(active)) + ) { + previousFocus.focus(); + } + }; + }, []); const explorerTxUrl = explorerBase && txHash ? `${explorerBase}/tx/${txHash}` : undefined; @@ -95,7 +116,7 @@ export function SuccessOverlay({ ref={dialogRef} role="dialog" aria-modal="true" - aria-live="assertive" + aria-labelledby={titleId} tabIndex={-1} className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-6 overflow-y-auto bg-emerald-500 px-4 py-8 text-white" > @@ -123,7 +144,7 @@ export function SuccessOverlay({ > ✓ -

{t('title')}

+

{t('title')}

{/* 巨大金額 (店主が遠くから視認できるサイズ) */} diff --git a/lib/trapModalFocus.ts b/lib/trapModalFocus.ts new file mode 100644 index 00000000..735037e8 --- /dev/null +++ b/lib/trapModalFocus.ts @@ -0,0 +1,47 @@ +// Tab 移動を DOM 順で制御する。初期 focus・Escape・close 時の復元は呼び出し側の契約を保つ。 +export function trapModalFocus(event: KeyboardEvent, dialog: HTMLElement) { + // IME の候補操作を奪わない (229 は isComposing が false になるブラウザの互換用)。 + if (event.key !== 'Tab' || event.isComposing || event.keyCode === 229) return; + // Safari の既定 Tab 設定でもボタンやリンクを辿れるよう、通常の移動も自前で行う。 + event.preventDefault(); + + // paywall の状態や details の開閉で対象が変わるため、Tab ごとに取り直す。 + // 対応はリンク・フォーム部品・summary・明示的な tabindex。contenteditable / iframe / + // media[controls] / area 固有のフォーカス規則は対象外 (それぞれの selector は含めない)。 + const focusables = Array.from(dialog.querySelectorAll( + 'a[href], button, input, select, textarea, summary, [tabindex]', + )).filter((element) => { + if (element.tabIndex < 0 || element.matches(':disabled')) return false; + // visibility は子で visible に上書きできるため、要素自身の computed 値だけを見る。 + const { visibility } = window.getComputedStyle(element); + if (visibility === 'hidden' || visibility === 'collapse') return false; + for (let node: HTMLElement | null = element; node; node = node.parentElement) { + if (node.hidden || node.hasAttribute('inert')) return false; + if (window.getComputedStyle(node).display === 'none') return false; + if (node instanceof HTMLDetailsElement && !node.open) { + const summary = node.querySelector('summary'); + if (!summary?.contains(element)) return false; + } + if (node === dialog) break; + } + return true; + }); + // 操作対象がない間も背後へ抜けず、初期 focus の dialog に留める。 + if (!focusables.length) { + dialog.focus(); + return; + } + const active = document.activeElement; + const index = active instanceof HTMLElement ? focusables.indexOf(active) : -1; + if (index >= 0) { + const step = event.shiftKey ? -1 : 1; + focusables[(index + step + focusables.length) % focusables.length].focus(); + return; + } + + // disabled 化などで一覧外になった active は、その DOM 位置から直後 / 直前を探す。 + const candidates = event.shiftKey ? focusables.slice().reverse() : focusables; + const position = event.shiftKey ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING; + const next = candidates.find((element) => active && (active.compareDocumentPosition(element) & position)); + (next ?? candidates[0]).focus(); +} diff --git a/tests/components/CsvPassModal.test.tsx b/tests/components/CsvPassModal.test.tsx index 02235091..0ed0ca70 100644 --- a/tests/components/CsvPassModal.test.tsx +++ b/tests/components/CsvPassModal.test.tsx @@ -30,6 +30,18 @@ function Parent({ onClose }: { onClose: (value: string) => void }) { } describe('CsvPassModal focus (B-R11e)', () => { + it('B-R11f: 操作対象が閉じるボタンだけでも Tab / Shift+Tab は外へ出ない', async () => { + const user = userEvent.setup(); + render(<>); + const close = screen.getByRole('button', { name: ja.CsvPass.close }); + await user.tab(); + expect(close).toHaveFocus(); + await user.tab(); + expect(close).toHaveFocus(); + await user.tab({ shift: true }); + expect(close).toHaveFocus(); + }); + it('親の再描画で外側の入力から focus を奪わず、Escape は最新の onClose を使う', async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/tests/components/ModalFocusTrap.test.tsx b/tests/components/ModalFocusTrap.test.tsx new file mode 100644 index 00000000..bac6cf7c --- /dev/null +++ b/tests/components/ModalFocusTrap.test.tsx @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { renderWithIntl as render } from '../_helpers/i18n'; +import { CsvPassModal } from '@/components/CsvPassModal'; +import { QrPreviewModal } from '@/components/QrPreviewModal'; + +// 支払い処理は置換し、状態によって変わる操作対象を本物の modal 内に置く。 +vi.mock('@/components/CsvPassPaywall', () => ({ + CsvPassPaywall: () => ( + <> + + Terms + + + +
+
+ + ), +})); + +function qrModal(withDetails = false, onClose = vi.fn()) { + return ( + ()} + storeName="Store" + amountText="100 JPYC" + chainText="Polygon" + copied={false} + onCopy={vi.fn()} + eip681={withDetails ? { + uri: 'ethereum:0xabc', + copied: false, + onCopy: vi.fn(), + title: 'Compatible QR', + badge: 'Advanced', + description: 'Fallback', + copy: 'Copy URI', + copiedLabel: 'Copied URI', + } : undefined} + /> + ); +} + +describe.each([ + ['QrPreviewModal', (onClose = vi.fn()) => qrModal(false, onClose)], + ['CsvPassModal', (onClose = vi.fn()) => ], +] as const)('%s focus trap (B-R11f)', (_name, modal) => { + it('初期 focus を保ち、Tab / Shift+Tab が先頭と末尾で循環する', async () => { + const user = userEvent.setup(); + render(<>{modal()}); + const dialog = screen.getByRole('dialog'); + const first = within(dialog).getAllByRole('button')[0]; + const last = within(dialog).queryByRole('link') ?? within(dialog).getByRole('button', { name: 'Copy' }); + expect(dialog).toHaveFocus(); + + await user.tab({ shift: true }); + expect(last).toHaveFocus(); + await user.tab(); + expect(first).toHaveFocus(); + await user.tab(); + const input = within(dialog).queryByRole('textbox'); + expect(input ?? last).toHaveFocus(); + if (input) await user.tab(); + expect(last).toHaveFocus(); + await user.tab(); + expect(first).toHaveFocus(); + await user.tab({ shift: true }); + expect(last).toHaveFocus(); + await user.tab({ shift: true }); + expect(input ?? first).toHaveFocus(); + + dialog.focus(); + await user.tab(); + expect(first).toHaveFocus(); + }); + + it('外へ移った focus は次の Tab / Shift+Tab で modal 内に戻る', async () => { + const user = userEvent.setup(); + render(<>{modal()}); + const dialog = screen.getByRole('dialog'); + const first = within(dialog).getAllByRole('button')[0]; + const last = within(dialog).queryByRole('link') ?? within(dialog).getByRole('button', { name: 'Copy' }); + const outside = screen.getByRole('button', { name: 'Outside' }); + outside.focus(); + await user.tab(); + expect(first).toHaveFocus(); + outside.focus(); + await user.tab({ shift: true }); + expect(last).toHaveFocus(); + }); + + it('ブラウザの標準 Tab 移動に依存せず、内部の次 / 前へ移動する', () => { + render(modal()); + const dialog = screen.getByRole('dialog'); + const first = within(dialog).getAllByRole('button')[0]; + const next = within(dialog).queryByRole('textbox') ?? within(dialog).getByRole('button', { name: 'Copy' }); + first.focus(); + // fireEvent は native の Tab 移動を行わない。false は preventDefault 済みを表す。 + expect(fireEvent.keyDown(first, { key: 'Tab' })).toBe(false); + expect(next).toHaveFocus(); + expect(fireEvent.keyDown(next, { key: 'Tab', shiftKey: true })).toBe(false); + expect(first).toHaveFocus(); + }); + + it.each([{ isComposing: true }, { keyCode: 229 }])('IME 中は Escape / Tab を処理しない (%j)', (ime) => { + const onClose = vi.fn(); + render(modal(onClose)); + const first = within(screen.getByRole('dialog')).getAllByRole('button')[0]; + first.focus(); + expect(fireEvent.keyDown(first, { key: 'Tab', ...ime })).toBe(true); + expect(first).toHaveFocus(); + fireEvent.keyDown(first, { key: 'Escape', ...ime }); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.keyDown(first, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); + +it.each([false, true])('disabled 化した active から DOM 上の次 / 前へ移動する (shift=%s)', (shiftKey) => { + render(); + const first = screen.getByRole('button', { name: '閉じる' }); + const input = screen.getByRole('textbox') as HTMLInputElement; + const last = screen.getByRole('link', { name: 'Terms' }); + input.focus(); + input.disabled = true; + expect(input).toHaveFocus(); + expect(fireEvent.keyDown(input, { key: 'Tab', shiftKey })).toBe(false); + expect(shiftKey ? first : last).toHaveFocus(); + + // DOM 上に次 / 前の候補が無ければ、反対側の端へ戻る。 + const edge = shiftKey ? first : last; + edge.focus(); + edge.tabIndex = -1; + expect(fireEvent.keyDown(edge, { key: 'Tab', shiftKey })).toBe(false); + expect(shiftKey ? last : first).toHaveFocus(); +}); + +it('祖先の visibility:hidden を子の visible で上書きでき、display:none は上書きできない', () => { + render(); + const visible = screen.getByText('Invisible'); + visible.style.visibility = 'visible'; + screen.getByText('Not displayed').style.visibility = 'visible'; + const dialog = screen.getByRole('dialog'); + expect(fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })).toBe(false); + expect(visible).toHaveFocus(); + expect(fireEvent.keyDown(visible, { key: 'Tab', shiftKey: true })).toBe(false); + expect(screen.getByRole('link', { name: 'Terms' })).toHaveFocus(); +}); + +it('QR の閉じた details は summary で循環し、展開後は内部のボタンまで辿れる', async () => { + const user = userEvent.setup(); + render(<>{qrModal(true)}); + const first = screen.getByRole('button', { name: 'Close' }); + const summary = screen.getByText('Compatible QR').closest('summary')!; + await user.tab({ shift: true }); + expect(summary).toHaveFocus(); + await user.tab(); + expect(first).toHaveFocus(); + + await user.click(summary); + summary.focus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'Copy URI' })).toHaveFocus(); + await user.tab(); + expect(first).toHaveFocus(); + + await user.click(summary); + first.focus(); + await user.tab({ shift: true }); + expect(summary).toHaveFocus(); + await user.tab(); + expect(first).toHaveFocus(); +}); diff --git a/tests/components/SuccessOverlay.test.tsx b/tests/components/SuccessOverlay.test.tsx index a6a6139f..4441d187 100644 --- a/tests/components/SuccessOverlay.test.tsx +++ b/tests/components/SuccessOverlay.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithIntl as render } from '../_helpers/i18n'; import { SuccessOverlay } from '@/components/SuccessOverlay'; @@ -96,6 +96,100 @@ describe('SuccessOverlay', () => { expect(onDismiss).toHaveBeenCalledOnce(); }); + it('B-R11f: onDismiss の更新で内部の focus を奪わず、Escape / close は最新のハンドラを呼ぶ', async () => { + const user = userEvent.setup(); + const previousDismiss = vi.fn(); + const onDismiss = vi.fn(); + const { rerender } = render( + , + ); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveFocus(); + const history = screen.getByRole('link', { name: /このブラウザの履歴を見る/ }); + history.focus(); + rerender(); + expect(history).toHaveFocus(); + expect(screen.getByRole('dialog')).toBe(dialog); + + await user.keyboard('{Escape}'); + expect(onDismiss).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: '閉じる' })); + expect(onDismiss).toHaveBeenCalledTimes(2); + expect(previousDismiss).not.toHaveBeenCalled(); + }); + + it('B-R11f: Tab / Shift+Tab が overlay 内を循環し、外からの Tab も引き戻す', async () => { + const user = userEvent.setup(); + render(<> + undefined} /> + + ); + const first = screen.getByRole('button', { name: '完了音をオフにする' }); + const copy = screen.getByRole('button', { name: /Tx Hash をコピー/ }); + const last = screen.getByRole('button', { name: '閉じる' }); + await user.tab(); + expect(first).toHaveFocus(); + await user.tab(); + expect(copy).toHaveFocus(); + await user.tab({ shift: true }); + expect(first).toHaveFocus(); + await user.tab({ shift: true }); + expect(last).toHaveFocus(); + await user.tab(); + expect(first).toHaveFocus(); + screen.getByRole('button', { name: 'Outside' }).focus(); + await user.tab({ shift: true }); + expect(last).toHaveFocus(); + }); + + it.each([{ isComposing: true }, { keyCode: 229 }])('B-R11f: IME 中は Escape / Tab を処理しない (%j)', (ime) => { + const onDismiss = vi.fn(); + render(); + const close = screen.getByRole('button', { name: '閉じる' }); + close.focus(); + expect(fireEvent.keyDown(close, { key: 'Tab', ...ime })).toBe(true); + expect(close).toHaveFocus(); + fireEvent.keyDown(close, { key: 'Escape', ...ime }); + expect(onDismiss).not.toHaveBeenCalled(); + fireEvent.keyDown(close, { key: 'Escape' }); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it('B-R11f: StrictMode の mount で focus し、unmount で起点に戻して listener を解除する', async () => { + const user = userEvent.setup(); + render(); + const opener = screen.getByRole('button', { name: 'Opener' }); + opener.focus(); + const onDismiss = vi.fn(); + const { unmount } = render( + , + { reactStrictMode: true }, + ); + expect(screen.getByRole('dialog')).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(onDismiss).toHaveBeenCalledTimes(1); + screen.getByRole('button', { name: '閉じる' }).focus(); + unmount(); + expect(opener).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('B-R11f: 外へ移した focus は再描画 / unmount でも奪わない', () => { + render(<>); + const opener = screen.getByRole('button', { name: 'Opener' }); + opener.focus(); + const { rerender, unmount } = render( + undefined} />, + ); + const outside = screen.getByRole('textbox', { name: 'Outside' }); + outside.focus(); + rerender( undefined} />); + expect(outside).toHaveFocus(); + unmount(); + expect(outside).toHaveFocus(); + }); + it('explorerBase 指定時は Tx Explorer リンクが描画される', () => { render( { expect(screen.queryByText('受付番号')).toBeNull(); }); - it('a11y: role="dialog" + aria-modal + aria-live=assertive', () => { + it('a11y: dialog の名前は可視見出しから導出し、読み上げ対象に時計を含めない', () => { render( { onDismiss={() => undefined} />, ); - const dialog = screen.getByRole('dialog'); + const dialog = screen.getByRole('dialog', { name: '決済完了' }); + const heading = screen.getByRole('heading', { name: '決済完了' }); expect(dialog.getAttribute('aria-modal')).toBe('true'); - expect(dialog.getAttribute('aria-live')).toBe('assertive'); + expect(dialog).toHaveAttribute('aria-labelledby', heading.id); + expect(dialog).not.toHaveAttribute('aria-live'); + expect(heading).toHaveAttribute('aria-live', 'assertive'); + expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/).closest('[aria-live]')).toBeNull(); expect(dialog.getAttribute('tabIndex')).toBe('-1'); });