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
7 changes: 5 additions & 2 deletions components/CsvPassModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 (
Expand Down
4 changes: 1 addition & 3 deletions components/QrGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -597,7 +595,7 @@ export function QrGenerator() {
{payUrl && (
<QrPreviewModal
open={qrModalOpen}
onClose={closeQrModal}
onClose={() => setQrModalOpen(false)}
labels={{
title: t('qrModalTitle'),
close: t('qrModalClose'),
Expand Down
7 changes: 5 additions & 2 deletions components/QrPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
Expand Down
37 changes: 29 additions & 8 deletions components/SuccessOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
// inline onDismiss の更新で focus effect を再実行せず、ESC は最新のハンドラを読む。
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
const [soundOn, setSoundOn] = useSuccessSoundPref();

// 決済完了チャイム (PayPay の「ペイペイ!」音 相当)。overlay マウント時に 1 回だけ
Expand All @@ -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;
Expand All @@ -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"
>
Expand Down Expand Up @@ -123,7 +144,7 @@ export function SuccessOverlay({
>
✓
</span>
<h2 className="text-3xl font-bold sm:text-4xl">{t('title')}</h2>
<h2 id={titleId} aria-live="assertive" className="text-3xl font-bold sm:text-4xl">{t('title')}</h2>
</div>

{/* 巨大金額 (店主が遠くから視認できるサイズ) */}
Expand Down
47 changes: 47 additions & 0 deletions lib/trapModalFocus.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(
'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();
}
12 changes: 12 additions & 0 deletions tests/components/CsvPassModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<><CsvPassModal open onClose={vi.fn()} /><button>Outside</button></>);
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();
Expand Down
Loading
Loading