1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

閉じるボタンは、帰り道まで閉じない――素のJavaScriptでフォーカスを戻す

1
Posted at

本を閉じるだけなら、簡単な仕事に見えます。

本を隠す。後ろの店内を、また操作できるようにする。片づけ完了です。

ただ、キーボードで操作している人には、もう一つ返すものがあります。本を開く直前に、操作していた場所です。 画面を片づけただけでは、読者の帰り道まで用意したことになりません。

個人開発のAfter 6 Streetでは、店内のボタンから、本を開くような読書画面へ移動できます。今回は、この画面を閉じたときのフォーカス復帰を取り出します。

「閉じたら戻す」は親切そうです。でも、閉じる処理を初期化にも使うと、少し話が変わります。まだ何も開いていない人まで、カウンターへ連れ戻しかねません。

この記事で扱うのは、本当に開いていた場合だけ操作元へ戻し、開いていなかった場合は現在のフォーカスに触らないという判断です。ライブラリなしで試せる、一枚のHTMLも載せます。

隠したのは本。見失うのは操作場所

フォーカスは、キーボード入力を受け取る場所です。本文をマウスで選択している範囲とは別で、通常は document.activeElement で現在の要素を参照できます。MDNの説明

たとえば、Tabで「棚Bの本を開く」まで進み、Enterで本を開くとします。読書画面を閉じたあとは、棚Bのボタンへ戻れば、その続きから操作できます。

ところが「本が消えれば終わり」にすると、その戻り先が抜けます。逆に、毎回決まった「カウンターの本」へ戻せばよいかというと、それも少し雑です。棚Bまで来た人を、店員の都合で入口付近へ案内し直すことになります。

WAI-ARIA APGのモーダルダイアログのパターンでも、閉じたときは原則として開いた操作元へ戻し、操作元が消えた場合などは、作業の流れに合う戻り先を選びます。Dialog (Modal) Pattern

そこで、開くときに操作元を覚え、閉じるときに返します。「どの棚から来たか」まで含めた返却です。カウンターに返却口があるからといって、すべてのお客さんをそこへ並ばせる必要はありません。

ところが、開いていない本も閉じる

ここからが、今回いちばん残したかった条件です。

次のような閉じる処理を考えます。これは問題を説明するための仮のコードです。

function closeBook() {
  overlay.hidden = true;
  room.inert = false;
  openButton.focus({ preventScroll: true });
}

この関数は、表示を「本が閉じている状態」にそろえるためにも使えます。しかし、初期表示で呼んでも、最後の focus() は実行されます。入力欄などにフォーカスがあれば、閉じた本がなくても、決まったボタンへ移してしまいます。

「閉じた状態にする」と「開いていた本を閉じる」は、最終的な見た目が同じでも、起きたことが違います。

Streetの実装も、URLに応じて表示を決める処理から closeBook() を呼びます。閉じるボタンだけの専属係ではありません。そのため、変更する前の表示状態を見てから、フォーカスを返すか決めています。

次は、参照した src/app.js の関係する行だけを抜き出したものです。間にある履歴・会話などの処理は省略しています。

const focusTarget = !$('.bk-overlay').hidden
  ? (state.focus && root.contains(state.focus)
      ? state.focus
      : $('.bk-open-volume'))
  : null;

// 履歴や会話に関する処理は省略
$('.bk-overlay').hidden = true;
$('.bk-room').inert = false;

// 関連する画面状態の更新は省略
focusTarget?.focus({ preventScroll: true });

開いていなかった場合、戻り先は null です。フォーカス復帰もしません。

ここで先に hidden = true にしてから「開いていたか」を調べると、毎回「閉じています」という返事になります。片づけたあとに「さっき散らかっていましたか」と聞いても、現場はもう残っていません。

帰り先が残っているか、帰れる状態か

実装では、開くときの document.activeElement を state.focus に記録しています。会話UIの中にフォーカスがある場合は、別の操作先へ補正する処理もあります。ここでは、通常のボタンから開く経路へ絞ります。

閉じるときの判断は、次の三つです。

閉じる前の状態 記録した操作元 フォーカスの扱い
開いていない あってもなくても 今いる場所のまま
開いている 店内のDOMに残っている 記録した操作元へ戻す
開いている 残っていない、または記録がない カウンターの本へ戻す

root.contains(returnTarget) は、要素が店内のDOMの配下に残っているかの確認です。「同じIDのボタンを作り直した」場合は、元の要素とは別物です。削除済みのボタンへの参照を握っていても、そこへ帰れるわけではありません。MDN:Node.contains()

順番も大事です。本を開いている間、店内側には inert を付けています。閉じたら店内側の inert を解除し、そのあとに操作元へ focus() します。

inert は、見た目を薄暗くするCSSではありません。その領域へのフォーカスや操作を無効にするものです。先にフォーカスだけ返そうとしても、戻り先が操作できる状態にはなっていません。MDN:HTMLElement.inert

preventScroll: true は、このフォーカス移動に伴うスクロールを抑える指定です。スクロール位置を保存・復元する処理とは別です。MDN:HTMLElement.focus()

なお、contains() だけでは、非表示・無効化された要素まで「戻れる」と保証できません。この縮小例では、操作元は表示中の有効なボタン、代替先は常に残るボタン、という条件に絞っています。表示条件が変わる画面なら、戻り先の有効性も設計に含めます。

小さな実装へ取り出す

以下はStreetの全実装ではなく、フォーカスを預かって返す部分を整理した記事用のコードです。

実際の実装は開く直前の activeElement を保存します。この例では、クリックされたボタンを open(button) に渡します。ブラウザーや操作方法によってクリック時のフォーカスの扱いが異なるため、「どのボタンで開いたか」を直接渡す形にしています。

function createReader({ root, room, overlay, firstFocus, fallback }) {
  let returnTarget = null;

  function open(opener) {
    if (!overlay.hidden) return;
    returnTarget = opener;
    overlay.hidden = false;
    room.inert = true;
    firstFocus.focus({ preventScroll: true });
  }

  function close() {
    const wasOpen = !overlay.hidden;
    const target = wasOpen
      ? (returnTarget && root.contains(returnTarget) ? returnTarget : fallback)
      : null;

    overlay.hidden = true;
    room.inert = false;
    returnTarget = null;
    target?.focus({ preventScroll: true });
  }

  return { open, close };
}

close() は、先に開いていたかを記録します。表示と inert を閉じた状態にそろえたあと、必要な場合だけ戻り先へフォーカスします。保存していた参照も、その回の返却が終わったら手放します。

open() の先頭では、すでに開いている場合を除外しました。この縮小例では、本の中で再度開く処理が呼ばれても、「元いた場所」を本の中のボタンで上書きしないためです。

そのまま保存して試せるHTML全文

以下を demo.html として保存し、ブラウザーで開いてください。外部ファイルやパッケージは不要です。

棚A・棚Bから開いて閉じる操作と、「開いたボタンを撤去して閉じる」操作を試せます。Escape、Tab、Shift+Tabへの対応も含めています。Tabの処理は、このデモにあるボタンだけを対象としたものです。

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>本を閉じたら、フォーカスはどこへ?</title>
<style>
*{box-sizing:border-box}body{margin:0;font:16px/1.9 system-ui,"Hiragino Kaku Gothic ProN",Meiryo,sans-serif;color:#243e39;background:#f4f7f4}main{max-width:780px;margin:auto;padding:30px 24px}h1{font-size:25px;line-height:1.6}h2{font-size:22px}label{display:block}input{width:100%;max-width:400px;margin:5px 0 15px;padding:10px;border:1px solid #a8bdb4;border-radius:6px;font:inherit}button{font:inherit;border:1px solid #8aaca0;border-radius:7px;padding:10px 15px;margin:5px 5px 5px 0;background:#fff;color:#234c40;cursor:pointer}button:hover{background:#e7f2eb}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:3px solid #d3732d;outline-offset:4px}[hidden]{display:none!important}.eyebrow{font-size:12px;color:#547b69;letter-spacing:.1em}.controls{padding:15px 0}.hint{font-size:14px;color:#597069}.state{font:13px/1.8 ui-monospace,monospace;padding:12px 15px;background:#e4eee8;border-radius:8px;overflow-wrap:anywhere}#overlay{position:fixed;inset:0;z-index:10;display:grid;place-items:center;padding:20px;background:#173a34aa}.paper{width:min(590px,100%);max-height:85vh;overflow:auto;background:#fffdf6;border-radius:12px;padding:25px;box-shadow:0 15px 60px #183c3244}code{background:#e7eee7;padding:2px 4px;border-radius:3px}a{color:#1c6f52}
</style>
<main id="shop">
  <div id="room">
    <p class="eyebrow">VANILLA HTML / CSS / JAVASCRIPT</p>
    <h1>本を閉じたら、フォーカスはどこへ?</h1>
    <label for="search">店内検索(デモなので検索は実行しません)</label>
    <input id="search" placeholder="この入力欄も、勝手に連れ戻されたくない場所です">
    <div class="controls">
      <button id="opener-a" data-open>棚Aの本を開く</button>
      <button id="opener-b" data-open>棚Bの本を開く</button>
      <button id="fallback" data-open>カウンターの本を開く</button>
    </div>
    <button id="normalise-close">閉じている状態でclose()を呼ぶ</button>
    <p class="hint">Tabでボタンへ移動し、Enterで本を開いてください。閉じると、開いたボタンへ戻ります。「開いたボタンを撤去して閉じる」では、カウンターの本へ戻ります。</p>
    <p class="hint">初期化時にもclose()を呼びます。何も開いていないので、フォーカスは動かしません。</p>
    <p id="focus-state" class="state" aria-live="polite"></p>
  </div>
  <section id="overlay" role="dialog" aria-modal="true" aria-labelledby="book-title" hidden>
    <div class="paper">
      <h2 id="book-title">閉じるための短い本</h2>
      <p>この本を読み終えても、読書家になったかどうかは判定しません。今回は、開いた場所だけ覚えておきます。</p>
      <p>閉じると、先ほど操作していたボタンにフォーカスが戻ります。戻り先が撤去されていたら、カウンターの本が代わりの戻り先です。</p>
      <button id="close-book">本を閉じる</button>
      <button id="remove-opener">開いたボタンを撤去して閉じる</button>
      <p class="hint">Escapeでも閉じられます。TabとShift+Tabは、この本の中を移動します。</p>
    </div>
  </section>
</main>
<script type="module">
function createReader({ root, room, overlay, firstFocus, fallback }) {
  let returnTarget = null;

  function open(opener) {
    if (!overlay.hidden) return;
    returnTarget = opener;
    overlay.hidden = false;
    room.inert = true;
    firstFocus.focus({ preventScroll: true });
  }

  function close() {
    const wasOpen = !overlay.hidden;
    const target = wasOpen
      ? (returnTarget && root.contains(returnTarget) ? returnTarget : fallback)
      : null;

    overlay.hidden = true;
    room.inert = false;
    returnTarget = null;
    target?.focus({ preventScroll: true });
  }

  return { open, close };
}

const root = document.querySelector('#shop');
const room = document.querySelector('#room');
const overlay = document.querySelector('#overlay');
const closeButton = document.querySelector('#close-book');
const fallback = document.querySelector('#fallback');
const removeButton = document.querySelector('#remove-opener');
const reader = createReader({ root, room, overlay, firstFocus: closeButton, fallback });
let opener = null;
for (const button of root.querySelectorAll('[data-open]')) {
  button.addEventListener('click', () => {
    opener = button;
    removeButton.disabled = button === fallback;
    reader.open(button);
  });
}
closeButton.addEventListener('click', reader.close);
document.querySelector('#normalise-close').addEventListener('click', reader.close);
removeButton.addEventListener('click', () => {
  if (opener && opener !== fallback) opener.remove();
  reader.close();
});
overlay.addEventListener('keydown', event => {
  if (event.key === 'Escape') {
    event.preventDefault();
    reader.close();
  }
  if (event.key !== 'Tab') return;
  const buttons = [...overlay.querySelectorAll('button:not([disabled])')];
  const first = buttons[0], last = buttons.at(-1);
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault(); last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault(); first.focus();
  }
});
function showState() {
  const focused = document.activeElement;
  document.querySelector('#focus-state').textContent =
    `focus: ${focused.id || focused.tagName.toLowerCase()} / hidden: ${overlay.hidden} / inert: ${room.inert}`;
}
document.addEventListener('focusin', showState);
reader.close();
showState();
</script>
</html>

ここでは既存の独自オーバーレイから判断を取り出しました。これから単純なモーダルを新しく作るなら、標準の <dialog> と showModal() も先に検討したいところです。モーダルとして開くと、同じ文書の背後を操作できない状態にするなど、ブラウザーが受け持つ部分があります。MDN:showModal()

「閉じられた」だけで合格にしない

記事用の縮小例は、Chrome 155/Linuxの実DOMで、次の結果を確認しました。画面が消えたかに加えて、document.activeElement が期待した要素かを調べています。

操作 確認結果
閉じた状態で検索欄へフォーカスし、close() 検索欄のまま
棚Aから開いて閉じる 棚Aへ戻った
棚Bから開いて閉じる 棚Bへ戻った
開くときのボタンを削除して閉じる カウンターの本へ戻った
一度閉じ、検索欄へ移ってから再度 close() 検索欄のまま
開いている間に再度 open() してから閉じる 最初の操作元へ戻った

開いたときは本の内側のボタンへ移ること、閉じたあとは店内の inert が解除されることも確認しました。デモでは、TabとEnterで開き、Shift+Tabの折り返し、Escapeで閉じて操作元へ戻る経路も操作しました。

Streetにある既存の tests-book-focus.mjs も、参照した実ソースに対して実行しました。こちらはNode.js上のスタブによる確認で、初期表示やすでに閉じた状態ではフォーカスを奪わず、開いていた場合は操作元または代替先へ戻すことを調べています。実ブラウザーの確認とは区別しています。

今回確かめたのは、この復帰判断と記事用デモです。Street全体のアクセシビリティ監査、スクリーンリーダーでの読み上げ、Safari・Firefox・実機での動作確認までは行っていません。role="dialog" や aria-modal="true" を付けただけで、キーボード操作が実装されるわけでもありません。

参照した実装と一次資料

実装の参照日は2026年11月3日(日本時間)です。Sites上の直近の公開完了記録はv86、対応コミットは c4a3802b523ca79bdc47eb94af77b5a7d03ce83b でした。最新の保存版はv88ですが、保存版数と公開完了は区別しています。今回はv86の src/app.js、src/store.html、tests-book-focus.mjs を読み取りました。記事のためのサイト改修や公開はしていません。

この記事のソース調査、縮小例の実装・検証、構成・文章作成に生成AIを利用しています。

後片づけの対象を、増やしすぎない

本を隠す。後ろの店内を、また操作できるようにする。

そこに、開く前の操作場所へ戻す仕事も加わりました。ただし、その仕事があるのは、本を開いていたときだけです。初期化のたびに、今いる場所から呼び戻す必要はありません。

本が閉じているなら、片づけは済んでいます。

お客さんまで定位置へ片づけなくていい。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?