こなさんみんばんわ。
なんか最近口の中をよく噛むんですが、おかげで舌の端とか腫れててめっちゃごはん食べにくいです😭

そんな話はどうでもいいとして、ようやく重い腰を上げてこの Web サイトのブログ記事にも目次が付けられるようにしてみました。併せて直近一年くらいの記事(全部ではない)と、それ以前の記事の中で特に今でも読まれているものについて、遡って目次を付けてみました。

という訳で、例によってその作業メモを残しておこうかと思います。

目次を出すこと自体はとても簡単

今まで目次を付けてなかった理由は単純に「何となく大変そうだ」と思ってたからなんですが、調べてみたら全然大変じゃなくて、実は Jekyll 標準の Markdown プロセッサーである Kramdown にその機能が付いてたんですよ…長いこと使ってるくせに全然知りませんでした。涙

やり方ですが、記事ファイルの目次を置きたい場所に次のようなコードを書くだけです。なおこのサイトでは目次を ol 要素として出力したかったので行頭記号に 1. を使ってますが、これを -*, + に変更すれば ul 要素で出力されますので、そこは任意で。

1. 目次(最終的に削除されるのでここのテキストは何でもいい)
{:toc}

これだけで次のような、記事内の見出しにジャンプできるアンカーリンクを含んだ目次のリストが出力されてきます。

<ol id="markdown-toc">
  <li><a href="#見出し1" id="markdown-toc-見出し1">見出し1</a></li>
  <li><a href="#見出し2" id="markdown-toc-見出し2">見出し2</a></li>
  <!-- 途中省略 -->
  <li><a href="#見出しラスト" id="markdown-toc-見出しラスト">見出しラスト</a></li>
</ol>

日本語だと見出しテキストがそのまま ID やアンカーリンクに含まれてしまうのが若干がっかりポイントですが、別にそれでリンクが動作しなくなる訳じゃないのでまぁええかという感じです。ちなみにこの挙動がどうしても嫌なら、毎回自力で見出しに ID を振れば回避はできます。

見出し1 {#heading-1}
--------------------
Lorem ipsum …

### 見出し1-1 {$heading-1-1}

まぁそれはそれで毎回(あと過去記事まで遡って)やるのも面倒なんで、とりあえず僕は気にしないことにしました。

意味付けなどもう少し色々やってみる

さて、これだけだと単に見出しのリストが出てくるだけなので、もう少しマークアップを付け足して意味を与えたりインタラクティブな要素を付け加えたりしてみます。

まずは別ファイルに分ける

毎回長々とマークアップを加えるのは面倒ですから、 Jekyll の Includes の仕組みを使い、別ファイルから都度読み込むようにします。先ほど例にあげた markdown 断片を page_toc.html という名前のファイルに書いて _includes フォルダに保存します。

これで記事目次を置きたいところに次のような Liquid タグを書くと page_toc.html の内容が読み込まれるようになります。

{% include page_toc.html %}

目次は主要なナビゲーションなので nav 要素でラップ

記事の目次はそのページの主要なナビゲーションであると考えることができますので、ここは nav 要素にご登場いただくのが筋かと思います。ということで、最初 page_toc.html は次のような感じにしました。ちなみに markdown="1" がないと中の markdown テキストがそのまま出力されるだけなので注意。

<nav class="page_toc" markdown="1">

## 記事目次

1. TOC
{:toc}

</nav>

目次を閉じられるように details 要素を使ってみる

ですが、ここで「目次を閉じられるようにしてみたい」という気分になってきました。JavaScript でやってもいいですが、ちょっと試しに details 要素を使ってみることにします。この要素には「折りたたみウィジェットを作成する」という以外の意味付けは特にないので、このような場面で使っても特に問題は発生しないはずです(という理解です)。

そうなると子要素の summary 要素はその内容を説明する見出しのような役割を果たすので、先ほどの page_toc.html に書いていた見出しがちょっと冗長になりますね。という訳で内容を次のように変更します。初期状態では目次を開いておきたいので details 要素には open 属性を付けています。

<nav class="page_toc">
<details open="" markdown="1">
<summary>記事目次<small>(クリック / タップで開閉)</small></summary>

1. TOC
{: toc }

</details>
</nav>

子ネタですが、{:toc} の部分はこのように空白を入れてもちゃんと機能するので、覚えておくといいと思います。何の役に立つかは分かりませんが🤣

マークアップの部分はとりあえずこれくらいにしておくことにしました。他にもやった方がよさそうなことはありますが、やってると時間ばかり過ぎてリリースできないので…涙

スタイルを付けていく

という訳で、スタイルシートを書いていきます。もう主要なブラウザでは CSS のネストとか普通に使えるらしいので、積極的に使っています1。細かい部分は除外して、考えたこととやったことについて触れていきます。

やりたいこと (1) 開いた状態では目次、閉じた時はボタンっぽく

初期状態(目次が開いてる時)ではボーダーで囲まれた目次部分がコンテンツの横幅いっぱいに広がってていいのですが、目次が閉じられた時には summary 要素内のテキストだけがボタンっぽく中央に配置されるようにしたいと考えました。色々考えた結果、次のようなのがシンプルでいいかな、となりました。

.page_toc {
  /* 省略 */
  details {
    margin: auto;
    /* 省略 */
    &:not([open]) {
      width: fit-content;
      /* 省略 */
    }
  }
}

details 要素が閉じられた状態では open 属性が削除されるので、その場合にだけ width: fit-content を適用して summary 要素の内容に幅が納まり、かつ最大でもコンテンツ幅を超えないようにしています。また details 要素には margin: auto が常に設定されているので、幅が縮んだ場合にはセンタリングされるという訳ですね。

やりたいこと (2) 画面幅の狭いデバイスではフォントサイズを小さく

ここまでの状態で色々なデバイス幅で確認してみたところ、スマートフォンのような画面幅の狭いデバイスだと目次のテキストに頻繁に折り返しが発生して、それだけで目次がけっこう長くなるしバランスも悪いように感じられましたので、その場合はフォントサイズが小さくなるようにしてみようと考えました。その部分の CSS です。

@media (width < 35em) {
  .page_toc ol[id] {
    font-size: 87.5%;
  }
}

このサイトは基本メディアクエリーのブレイクポイントを em 単位で設定してるので、例えば「幅 35em 未満」で切り替えするためには今までは @media not all and (min-width: 35em) と書かないといけなかったんですが、最近はこれも等号・不等号でシンプルに書けるようになりまして、楽になったものだなぁと感じます。

セレクタが .page_toc ol[id] なのは…気分ですね😅 要するにネストが深くなってどんどんフォントサイズが小さくなっていくという事象を避けるために、最上位の ol 要素にだけ font-size: 87.5% を当てたいと。

で、kramdown が出力してくる目次には、最上位のリストにのみ markdown-toc という id が付くので、それを素直に使ってもいいですが、詳細度高すぎていやじゃないですか。なのでそれを避けつつその要素を指定したい、かつ極力セレクタを短く書きたいと考えた結果、こうなりました。

えっ rem で書けばいい? それは宗教上の理由で無理😇 まぁそれは冗談ですが、何となくフォントサイズはパーセンテージ指定でないとダメなような気がして、なかなか rem 単位使いに踏み切れないんですよ。困ったもんです。

とはいえ最近はパディングあたりを rem で指定するように変えてますし、そろそろその辺は宗旨替えしていきたいところであります。以上余談でした。

やりたいこと (3) フォーカス・リングを目次全体に広げたい

キーボード操作で details 開くと summary の外側にのみフォーカス・リングが付くのを details の外側全体まで広げたい

tab キーで要素間を移動していった際に、目次にさしかかったところでフォーカスが当たるのは details 要素じゃなくて summary 要素なんですが、この際にフォーカス・リングが付くのはもちろん summary 要素の周囲です。

ですが、閉じた状態でフォーカス・リングが付いてる場合、それは details 要素全体に当たってる…ように感じられなくもないですよね。で、そこから開いた状態に移行すると summary 要素の周囲にしかリングがないという状態になる。これがどうも違和感があって、何となく気になってしまいまして…

なので、どうせなら開いた状態でも目次の枠全体にフォーカス・リングが付いている状態にできないものかなぁ…というのが今回の趣旨です。

:focus-within 擬似クラスを使う…だけではダメ

とりあえず details 要素の子孫要素にフォーカスが当たった際に details 要素そのものにフォーカス・リングを与えるには :focus-within 擬似クラスを使えばいいのですが、それだけでは今回の意図に沿ったスタイルにはなりません。

.page_toc {
  details {
    &:focus-within {
      outline: auto;
    }
  }
}

なぜなら目次の中に複数の a 要素があるので、これらにフォーカスが移動しても details 要素にフォーカス・リングが付いたままだからです。details 要素のフォーカス・リングは現在その開閉をキーボードで操作できることを示すために付けている訳ですから、そうでない場合には消えていただかないといけないのです。

:focus-within 擬似クラス + :has() 擬似クラス

そこで :has() 擬似クラスを使ってさらに状態を絞り込みます。ここでは a 要素ではなく summary 要素にフォーカスが当たっている場合(かつフォーカス・リングが必要な場合)にのみフォーカス・リングが付けばいい訳ですから、こんなセレクタでいかがでしょうか。

.page_toc {
  details {
    &:focus-within:has(summary:focus-visible) {
      outline: auto;
    }
  }
  summary:focus-visible {
    outline: none;
  }
}

とりあえずはこれで、自分の意図に沿ったフォーカス・リングの表示が確保できました。あとは元からある summary 要素のフォーカス・リングを消すだけですね(上のコードに示しています)。

…で終わればいいのですが、これだけではまだ問題があります。

問題 [1] :has() 擬似クラスを理解しないブラウザの考慮

:has() 擬似クラスは現在 Baseline 2023 Newly Available なので、まだこれを理解しないブラウザに遭遇する可能性が少しあることは頭に入れておく必要があります。何が言いたいかというと、そういうブラウザでは上のコードのままでは details にも summary にもフォーカス・リングが付かないという状態が発生してしまうということです。

無視されても問題ないスタイルなら気にしなくても構わないのですが、フォーカス・リングの有無はアクセシビリティに関わってくることなので、そういう訳にはいきません。なので @supports ルールを使用して :has() 擬似クラスによるセレクタを認識する場合にのみスタイルが適用されるようにしておきます。

@supports selector(:has(a)) {
  /* セレクタはまとめました */
  .page_toc details:focus-within:has(summary:focus-visible) {
    outline: auto;
  }
  .page_toc summary:focus-visible {
    outline: none;
  }
}

なお、セレクタの対応状況を検査する selector() はごくごく一部のブラウザ (Opera Android) で動作しないようなのですが、その場合はこのブロック自体が無視されて単にデフォルトのフォーカス・リングの挙動になるだけなので、関連するスタイルをこうやってまとめておけばそういうブラウザでも問題は発生しないです。

問題 [2] Google Chrome でフォーカス・リングが黒色になる

ここまででほぼ問題は解決しましたがあとひとつ、Firefox や Safari では問題ないですが Chrome では details 要素外周に付くフォーカス・リングが黒色(厳密に言えばテキスト色)になってしまうんです。開発者ツールでチェックした限りだと他のブラウザでも黒くなりそうなものなのですが、そうはならないんですね。謎です。

これは outline-color: -webkit-focus-ring-color を与えてやれば解決します。Firefox はこの色を理解しないので無視しますし、Safari も元からこの色が UA スタイルシートでの :focus-visible のデフォルト色なので、副作用は特にないと思います。という訳で、先のブロックはこうなりました。

@supports selector(:has(a)) {
  .page_toc details:focus-within:has(summary:focus-visible) {
    outline: auto;
    outline-color: -webkit-focus-ring-color;
  }
  /* 省略 */
}

このルールは似たようなシチュエーションで他の要素にデフォルトのフォーカス・リングを与える際にも使えそうな気がしますね。まぁ他の要素で同じような挙動になるかの検証はしてませんが…

やろうと思って実際やってみたけど採用しなかったこと

いい機会なので色々とスタイリングの実験をやってみてたんですが、試したけどやっぱ不要かなと思って採用しなかったスタイルがいくつかあります。

summary 要素の三角形マークを削除してアイコンにする

Chrome と Firefox では summary 要素の displaylist-item なのでこれを block などに変えることで、Safari では ::-webkit-details-marker という擬似要素を非表示にすることで、頭の三角形マークを消すことができます(参考文献: MDN)。

summary {
  display: block;

  &::-webkit-details-marker {
    display: none;
  }
}

実際にやってみまして代わりに SVG 画像などを置いてみたりもしたんですが、何となくこの三角形が消えてしまうと「開閉できるんやぞ!」感が薄れてしまうような気がしまして😅 今回は見送ることにしました。

開閉に応じて summary のテキストを「開く」や「閉じる」にする

details 要素の開閉状態に応じて open 属性が付いたり消えたりするのを利用すれば、その状態によって summary 要素内のテキストを変更することも可能ではあります。

<details>
  <summary>記事目次<small>(クリック / タップで<span class="to_close">閉じる</span><span class="to_open">開く</span>)</small></summary>
  <!-- 以下省略 -->
</details>
details[open] .to_open,
details:not([open]) .to_close {
  display: none;
}

以上のようにやれば、

  • 開いた状態では「開く」が非表示になり「クリック / タップで閉じる」に
  • 閉じた状態では逆に「閉じる」が非表示で「クリック / タップで開く」に

となって、コンテキストに応じたテキストを出し分けることができるのですが、今回は summary をセンタリングしてるので、出し分けるとテキスト量が一文字分違うので開閉によってテキスト位置が若干ずれてしまうんですよね。

それがちょっと気になってしまったので、こちらも採用を見送りました。

スタイリングに関しての解説はこんなところですね。あとはボーダー付けたりマージンやパディングを調整してるだけなので、説明が必要なほど難しいところはないはずです。

余談: なぜ今回クラス属性をあまり付けてないか

ところで、ここまでのコードはほぼ実際に使ってるコードそのままなのですが、気が付いた人がいるかもしれませんがあまりクラスを振ってないんですよね。details の親となる nav 要素に付いてるくらいです。

これ、最初は律義にクラス属性を付けてたんですよね。details には page_toc-content とか、summarypage_toc-title とか。ですがその状態でスタイルを書いてるうちに、なんか分かりにくいなぁ…と感じてきまして。

なぜかというと details 要素のようなウィジェットはその状態とスタイルが密接に関わり合ってますので、例えば「open 属性が付いたら表示が変わる」というスタイルを書くのなら

.page_toc-content[open] {…}

とやるよりは

details[open] {…}

の方が、より「なぜここで [open] 属性セレクタを書いてるのか」ということが分かりやすい気がしたんですよね。details 要素があれば open 属性が付くことは自明ですが、クラスだとそうはいきません。

フォーカスのスタイルに関しても、要素型セレクタで書いておけば「ああ、子の summary にフォーカスが当たった時に details にフォーカス・リングを付けたいんだな」とすぐ分かりますが、クラス属性ではその辺がよく分からないというか、実際にこのクラスがどの要素に使われてるのかを調べないとその意図が分からない…ということが発生しないとも限りません。

<details class="details"> とか <summary class="summary"> にすれば多少解決するかもしれませんが、そんな要素をなぞるだけのクラス名にあんまり意味なくない? とか、僕は思ってしまいます。

そもそもクラス名に対してスタイルを与えましょうというのは、あとでマークアップが変わる可能性があるし、そうなった時に CSS を書き直さなくて済むように…という理由が大きかったと思います。でも detailssummary のような関係性は絶対に崩れるものじゃないし、そう簡単にマークアップの修正が入るとも思えないんですね。

だったらそういったものに関しては、無理にすべてクラス名を与えなくとも、せいぜい親のクラス名 + 要素型セレクタくらいでスタイリングして、パッと見て「ああ、要素間の関連性でこういうスタイルを付けようとしてるのか」というのが把握できた方が、より分かりやすい CSS になるのではないかな? と思って、今回はこのようにしてみました。
まぁ実際どうなるかはこの先になってみないと分かりませんが…

あと、この先 @scope アットルールがすべてのブラウザで使えるようになれば、こういった CSS を書いても容易に破綻しづらくなるだろう、という期待もあります。がんばれ Mozilla. 涙

そんな訳で

今回は、ブログに記事目次を入れられるようにしたという話と実際に直近記事に目次を入れてみましたというご報告、そしてそれに関連する Jekyll の構成や、実際に書いた HTML・CSS の内容に関する作業メモを書き記してみました。

最近はあまり Jekyll でサイトを作る人も(特に新規では)いないんじゃないかとは思っておりますが、detailssummary 要素に関する話やそのスタイリング、特にフォーカス・リングにまつわる話あたりは Jekyller(誰)じゃない普通に Web 制作してる人たちに対しても、割と知見の得られる内容になってるのではないかと思っております。何かの参考になりましたら幸いです。

ところで、ここまでお読みいただいたみなさんにぜひお伺いしたいんですが…

この記事めっちゃ長くなってしまったんですけど、目次役に立ってる?😭

  1. 実は現在の環境にはちょっと問題があって、新しい構文を使うには考えないといけないのですが、その話はまた後日にします。