Skip to content

fix(a11y): QrCode の aria-live 連呼問題を sr-only debounce 通知で解消 (#435) - #436

Merged
fumtas1k merged 2 commits into
developfrom
fix/issue-435-qr-aria-live-debounce
May 14, 2026
Merged

fix(a11y): QrCode の aria-live 連呼問題を sr-only debounce 通知で解消 (#435)#436
fumtas1k merged 2 commits into
developfrom
fix/issue-435-qr-aria-live-debounce

Conversation

@fumtas1k

Copy link
Copy Markdown
Owner

サマリー

Closes #435QrCodearia-live region が入力 1 文字ごとに発火して SR で「QRコード: h, QRコード: ht, ...」と連呼される問題を、視覚プレビューと SR 通知を分離する案 B で解消。

変更内容

  • 視覚プレビュー div から role="status" aria-live="polite" を撤去(即時表示は維持)
  • 新たに sr-only な <span role="status" aria-live="polite" data-testid="qr-announcement"> を追加
  • svgHtml の変化を 300ms debounce してから「QRコードを生成しました」を announce
  • 連続入力中は useEffect cleanup で timer をキャンセル + announcement を空に戻すことで、同一文言でも入力 stable のたびに毎回 SR が変化検知できる設計

test-gates 陽性対照

debounce 機能と構造的回帰の 2 軸を、それぞれ独立した test() に分離して追加:

Test 観測対象 旧実装に当てた場合
A: debounce タイミング 200ms 時点で qr-announcement が空、400ms 時点で「QRコードを生成しました」 debounce 無し直貼り実装には qr-announcement testid 要素自体が無く toHaveText 段で timeout fail
B: 視覚プレビューに live region なし qr-code-container の祖先に aria-live を持つ要素が無く、[aria-live="polite"]qr-announcement ただ 1 つ 旧実装は視覚 div に aria-live があるため liveAncestors.toHaveCount(0) で fail

検証

  • node_modules/.bin/astro check → 0 errors / 0 warnings / 0 hints
  • npm run test → 1247 件 pass
  • npm run test:e2e -- qr-code.spec.ts a11y-live-region.spec.ts → 17 件 pass
  • npm run format:check → All files use Prettier code style

Test plan

  • CI green
  • QR コードページで長い URL を入力中、SR が連呼しないこと(macOS VoiceOver 等で手動)
  • 入力 stable から 300ms 後に「QRコードを生成しました」が 1 度だけ announce されること

Closes #435


Generated by Claude Code

issue #386 対応で QR プレビュー親 div に role="status" aria-live="polite" を
直貼りしていたが、useEffect が text/errorLevel 変化のたびに SVG を再生成する
ため、入力 1 文字ごとに live region が変化と判定され「QRコード: h, QRコード:
ht, ...」と SR で連呼される問題があった。

- 視覚プレビュー div から role="status" / aria-live を撤去 (即時表示は維持)
- 新たに sr-only な span(role="status" aria-live="polite") を追加し、
  svgHtml 変化を 300ms debounce してから「QRコードを生成しました」を announce
- 連続入力中は cleanup で timer キャンセル + announcement を空に戻すことで、
  同一文言でも入力 stable のたびに毎回 SR 検知される設計
- E2E 陽性対照 2 件追加:
  - debounce タイミング (200ms 時点で空 / 400ms 時点で本文)
  - 視覚プレビュー祖先に aria-live が無い構造 (旧実装回帰検知)

Closes #435
@github-actions

github-actions Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

🖼️ Visual Regression Test 結果

  • Status: ✅ 全 40 件 pass
  • Workflow run: 25843579075
  • Artifact (diff 画像 / playwright-report): 上記 workflow run の Artifacts セクションから download

diff が 意図的な visual 変更の場合: Update Visual Regression Baseline workflow を本 PR ブランチで workflow_dispatch trigger して baseline を更新。
diff が 意図しない regression の場合: 該当変更を fix。
本 check は required ではないため fail のままでも merge は可能(reviewer 判断)。

@fumtas1k fumtas1k self-assigned this May 14, 2026

Copy link
Copy Markdown
Owner Author

レビュー (#436, sha e486a4e)

#434 のフォロー issue (#435) として、aria-live 連呼問題を「視覚と SR 通知の分離 + debounce + 空文字経由の再 announce」で解消した PR。設計判断と陽性対照テストの作り込みが丁寧で、おおむね approve 寄り。下記は flake 耐性と軽微な確認点が中心。


✅ 設計判断の妥当性

sr-only debounce 分離パターン

src/components/tools/QrCode.tsx:74-86 の useEffect 設計:

useEffect(() => {
  if (!svgHtml) {
    setAnnouncement('');
    return;
  }
  setAnnouncement('');
  const t = setTimeout(() => setAnnouncement('QRコードを生成しました'), 300);
  return () => clearTimeout(t);
}, [svgHtml]);

入力中は空文字に戻し、stable してから 300ms 後に短文を出す」設計は SR の動作要件を正しく満たす:

  • 連続入力中: cleanup → setAnnouncement('') → timer 開始 を毎キーストロークで繰り返すため、live region の表示は常に空 (SR は無音)
  • 入力 stable: 300ms 後にタイマー発火、live region が '' → 'QRコードを生成しました' に遷移 → SR が変化検知して announce
  • 同一文言の再 announce 問題: 空文字を経由する設計により、同じテキストが 2 回連続でも SR が「テキスト変化あり」と認識する。コメントで明示されており意図が伝わる。

live region の DOM presence

sr-only span を svgHtml の有無に関わらず常に render している点 (QrCode.tsx:131-133) も正しい。WAI-ARIA のベストプラクティスでは「live region は DOM に最初から存在させ、後から動的追加しない」とされており、これに沿っている。

role="status" + aria-live="polite" の二重指定

role="status" が implicit に aria-live="polite" を持つため厳密には冗長だが、一部 SR (NVDA の特定バージョン等) で片方しか拾わない既知ケースがあるため defensive doubling として妥当。


🟡 Consider — debounce timing E2E の flake 耐性

tests/e2e/qr-code.spec.ts:104-119 の debounce 検証:

await page.getByLabel('テキスト / URL').fill('https://example.com');
await page.waitForTimeout(200);
await expect(announcement).toHaveText('');   // ← (1)
await page.waitForTimeout(200);
await expect(announcement).toHaveText('QRコードを生成しました');  // ← (2)

理論計算は 200ms < 300ms < 400ms で正しい。ただし下記 2 点が潜在的な flake 源:

  1. fill から useEffect commit までのラグ: fill 完了 → React onChange → svgHtml setState → commit → announce effect 起動 までに 50-100ms かかるケースがある (CI Linux runner では特に)。タイマー開始時刻が fill 完了時刻より 100ms 遅れると、(2) の T_total=400ms 時点で実タイマー経過は 400 - 100 = 300ms ピッタリで、設定値と一致して micro-task 順序次第で fail する。
  2. toHaveText('') の polling 性: Playwright の assertion は default 5s で polling するため、(1) で「すでに announce 済み」状態だと「空に戻る」のを 5s 待ち続けて fail。逆に (1) が false positive で pass する状況はないため、これは「flake で fail」方向にしかブレないという意味では安全側。

提案: マージンを広げると安定する。

// 例: 100ms / 500ms に変更 (合計 600ms > 300ms × 2 倍の余裕)
await page.waitForTimeout(100);
await expect(announcement).toHaveText('');
await page.waitForTimeout(500);
await expect(announcement).toHaveText('QRコードを生成しました');

もしくは debounce 定数を const DEBOUNCE_MS = 300 として export し、テスト側でも参照する形にすると将来チューニング時に test も追従しやすくなります (任意)。


🟢 Nits / 観察

  • QrCode.tsx:82setAnnouncement('') は前 render と同値の場合 React が bail out するため実害なし。意図的なリセットとしてコメント上の説明と整合。
  • 視覚プレビュー div から role="status" を外したことで、SR ユーザーは「QR コードが生成された事実」を sr-only 通知から、「内容 (URL)」を SVG <title> への focus 移動から、と 2 段で知ることになる。これは [P2] perf(qr-code): aria-live region が入力 1 文字ごとに発火する問題を debounce #435 で議論された trade-off の B 案そのもので、PR 本文と整合している。
  • chord 的な操作 (errorLevel を変えるトグル) でも svgHtml が更新されるので announce が走る (effect deps は [svgHtml] だが、svgHtml が [text, errorLevel] 依存で生成される結果)。意図通り。
  • E2E 構造的回帰 test B の xpath=ancestor-or-self::*[@aria-live]toHaveCount(0) と、page 全体での [aria-live="polite"]toHaveCount(1) で挟む二重防衛は強力。視覚 div への live region 復活も、別の場所への意図しない追加も検知できる。

サマリ

観点 判定
設計 (debounce + 空文字経由)
live region DOM presence
#434 で達成した accessible name は維持 ✅ (既存 test を container 起点に書き換え)
陽性対照テストの構造的設計 ✅ (debounce 機能 / 構造的回帰の 2 軸独立 test)
timing E2E の flake 耐性 🟡 (マージン拡大推奨)
project 規約準拠

🟡 は本 PR で対応するも独立 follow-up にするも判断可。それ以外は LGTM 🎉


Generated by Claude Code

PR #436 レビュー指摘対応。200ms / 200ms (合計 400ms) では React commit
ラグ (50-100ms) で 300ms 境界とほぼ同時となり CI runner で flake になりうる。
100ms / 500ms (合計 600ms) にして境界の前後双方で十分なマージンを確保する。

陽性対照設計 (旧実装に当てれば qr-announcement testid なしで fail) は不変。

Copy link
Copy Markdown
Owner Author

レビューありがとうございます。🟡 timing flake 耐性のみ対応しました (commit ea64459)。

対応

tests/e2e/qr-code.spec.ts の debounce timing マージンを 200ms / 200ms (合計 400ms) → 100ms / 500ms (合計 600ms) に拡大。

  • 100ms 時点 toHaveText('') チェックは 300ms 境界より十分手前
  • 500ms 追加で React commit ラグ (50-100ms) を吸収しても境界を確実に跨ぐ

陽性対照設計 (旧実装には qr-announcement testid 要素自体が無く getByTestId(...).toHaveText で fail) は不変です。

検証

  • npm run test:e2e -- qr-code.spec.ts → 9 件 pass (新 timing で安定)
  • node_modules/.bin/astro check / npm run format:check 既に green

DEBOUNCE_MS 定数化について

レビュー任意提案の「DEBOUNCE_MS を export して test と実装で共有」は本 PR では見送りました。理由は (1) 現状 1 箇所のみで使用、(2) 「シンプルなら良い」を優先、(3) 将来 timing を調整する際に export 化する判断は別 PR で議論可能。必要なら follow-up issue を切ります。


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

再レビュー (#436, sha ea64459)

前回指摘の 🟡 timing flake 耐性が解消されています。LGTM 🎉


✅ 解消確認

🟡 → ✅ debounce E2E の timing マージン

tests/e2e/qr-code.spec.ts:115-122:

// 100ms 時点では debounce 待ち中で空のまま (300ms 境界より十分手前)
await page.waitForTimeout(100);
await expect(announcement).toHaveText('');

// さらに 500ms 待つと debounce 通過し announce される (合計 600ms > 300ms に余裕)
await page.waitForTimeout(500);
await expect(announcement).toHaveText('QRコードを生成しました');
  • 100ms 側: 300ms 境界まで 200ms の余裕 → CI Linux runner で React commit ラグ (50-100ms) が乗っても境界に達しない
  • 500ms 側: 累計 600ms (300ms × 2 倍) → タイマー発火後に十分到達
  • コメントにも「React commit ラグ (50-100ms) を吸収しても境界 (300ms) を確実に跨ぐマージンを取る」と意図が明記されており、将来 debounce 定数を調整するときの根拠が残っている

定数 export 化までは踏み込まず、マージン拡大のみで対処した判断はコスパとしても妥当 (test と production code の同期は将来 issue 化でも OK)。


🟢 残課題 (本 PR 外で OK / 任意)

  • debounce 定数 (300) の const DEBOUNCE_MS = 300 への抽出と test 側からの参照は、将来 a11y チューニング (例: 500ms に変更) で test も自動追従させたくなったタイミングで切れば十分。
  • macOS VoiceOver 等での手動検証 (PR 本文 Test plan の [ ] 残り 2 つ) は merge 後ステージング確認推奨。

サマリ

観点 前回 今回
設計 (sr-only debounce 分離)
live region DOM presence
accessible name の維持 (#434 成果)
陽性対照テスト 2 軸 (debounce / 構造)
debounce E2E の flake 耐性 🟡 ✅ (100ms / 500ms にマージン拡大)
project 規約準拠

approve します。


Generated by Claude Code

@fumtas1k
fumtas1k merged commit 3b09dee into develop May 14, 2026
3 checks passed
@fumtas1k
fumtas1k deleted the fix/issue-435-qr-aria-live-debounce branch May 14, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] perf(qr-code): aria-live region が入力 1 文字ごとに発火する問題を debounce

2 participants