WordPressには、インストールするだけで h
タグから目次を生成するプラグインがあります。これを静的なページで同じようなことをしようと思うと、プラグインはもちろん使えないので、何か動的なコードを仕込む必要があります。
そんな h
タグを取得して目次生成とスクロールに応じた表示変更するものを、JavaScriptで作ったのがこの記事のスニペットです。
コード量は多少多めですが、簡単に設置できるので是非参考にしてくれたら嬉しいです。
目次
目次
.addEventListener()と.hasAttribute()
この記事で使っているメソッドの中から .addEventListener()
と .hasAttribute()
の2種類を抜粋して解説します。
2個抜粋した理由は「よく使う」からです。それだけです。
.addEventListener()
JavaScriptの .addEventListener()
は、クリックなどのイベントを設定することができるメソッドです。
.addEventListener('イベント', 処理(関数), オプション)
この記事では、最初の HTML 文書の読み込みと解析が完了ときに発動する「DOMContentLoaded」を使っています。
これ以外にもいろんな条件で条件設定でき、以下の記事がとてもわかりやすいので是非チェックしてみてください。
参考 EventTarget.addEventListener()MDN Web Docs.hasAttribute()
JavaScriptの .hasAttribute()
は、指定の要素が指定の属性を持っているかどうか?を返します。
例えば、HTMLとJavaScriptで以下のような一文があったとします。
<a id="idname" href="#">TARGET LINK</a>
<script>
// IDで指定して要素を取得
var element = document.getElementById( "idname" ) ;
// 実行
element.hasAttribute( "id" ) ; // idを持っている要素か? = true
element.hasAttribute( "class" ) ; // classを持っている要素か? = false
</script>
この場合JavaScriptでは、まずはじめに .getElementById()
で「idname」のid名を持つ要素を取得して、次に .hasAttribute()
で「idを持つ要素か?」「classを持つ要素か?」の2つを実行して真偽値を返しています。
このように、要素が指定した属性を持つか確認する時に使うのが.hasAttribute
で、この記事のスニペットでは if else
の条件分岐で使用しています。
閲覧中の見出しで目次が変わるサンプル
早速サンプルです。閲覧可能なのはパソコンのみですが、ページを開いて少しすると左側に目次が表示されます。
表示された目次は、常時ウィンドウの左側に固定表示されながら、スクロールして通過した見出しの h
タグを取得して、当該箇所にフォーカスがあたるようにしています。
モバイルだと表示されないので、その場合は上記の動画でサンプルをご覧ください。
サンプルのコード
コードはHTML・JavaScript・CSSの3種類です。順に解説していきます。
HTML
HTMLは、「toc js-toc」のclassを付けた div
タグを親要素にして、その中に p
タグ と ul
タグを入れたシンプルな構造です。
<div class="toc js-toc">
<p>目次</p>
<ul id="js-toc-list"></ul>
</div>
後述のJavaScriptのコードで、この ul
タグの中に li
タグで各目次の項目を配置していきます。
JavaScript
JavaScriptは、大きく分けて「目次を作るコード」と「ページの見出しタグを取得」の2つです。
// 目次の生成
document.addEventListener('DOMContentLoaded', function() {
htmlTableOfContents();
} );
function htmlTableOfContents( documentRef ) {
var documentRef = documentRef || document;
var toc = documentRef.getElementById("js-toc-list");
var headings = [].slice.call(documentRef.body.querySelectorAll('.entry-content h1, .entry-content h2, .entry-content h3')); /* ここで目次で取得するタグを指定 */
headings.forEach(function (heading, index) {
var ref = "toc" + index;
if ( heading.hasAttribute( "id" ) )
ref = heading.getAttribute( "id" );
else
heading.setAttribute( "id", ref );
var link = documentRef.createElement( "a" );
link.setAttribute( "href", "#"+ ref );
link.textContent = heading.textContent;
var div = documentRef.createElement( "li" );
div.setAttribute( "class", heading.tagName.toLowerCase() );
div.appendChild( link );
toc.appendChild( div );
});
}
try {
module.exports = htmlTableOfContents;
} catch (e) {
}
// ページの見出しタグを取得
function ready(fn) {
document.addEventListener('DOMContentLoaded', fn, false);
}
ready(() => {
const motionQuery = window.matchMedia('(prefers-reduced-motion)');
const TableOfContents = {
container: document.querySelector('.js-toc'),
links: null,
headings: null,
intersectionOptions: {
rootMargin: '0px',
threshold: 1
},
previousSection: null,
observer: null,
init() {
this.handleObserver = this.handleObserver.bind(this);
this.setUpObserver();
this.findLinksAndHeadings();
this.observeSections();
this.links.forEach(link => {
link.addEventListener('click', this.handleLinkClick.bind(this));
});
},
handleLinkClick(evt) {
evt.preventDefault();
let id = evt.target.getAttribute('href').replace('#', '');
let section = this.headings.find(heading => {
return heading.getAttribute('id') === id;
});
section.setAttribute('tabindex', -1);
section.focus();
window.scroll({
behavior: motionQuery.matches ? 'instant' : 'smooth',
top: section.offsetTop - 15,
block: 'start'
});
if (this.container.classList.contains('is-active')) {
this.container.classList.remove('is-active');
}
},
handleObserver(entries, observer) {
entries.forEach(entry => {
let href = `#${entry.target.getAttribute('id')}`,
link = this.links.find(l => l.getAttribute('href') === href);
if (entry.isIntersecting && entry.intersectionRatio >= 1) {
link.classList.add('is-visible');
this.previousSection = entry.target.getAttribute('id');
} else {
link.classList.remove('is-visible');
}
this.highlightFirstActive();
});
},
highlightFirstActive() {
let firstVisibleLink = this.container.querySelector('.is-visible');
this.links.forEach(link => {
link.classList.remove('is-active');
});
if (firstVisibleLink) {
firstVisibleLink.classList.add('is-active');
}
if (!firstVisibleLink && this.previousSection) {
this.container.querySelector(`a[href="#${this.previousSection}"]`).classList.add('is-active');
}
},
observeSections() {
this.headings.forEach(heading => {
this.observer.observe(heading);
});
},
setUpObserver() {
this.observer = new IntersectionObserver(this.handleObserver, this.intersectionOptions);
},
findLinksAndHeadings() {
this.links = [...this.container.querySelectorAll('a')];
this.headings = this.links.map(link => {
let id = link.getAttribute('href');
return document.querySelector(id);
});
}
};
TableOfContents.init();
});
この2つのコードは .addEventListener()
の「DOMContentLoaded」で、HTML の初期文書が完全に読み込まれたタイミングで発動。そして、スクロールしながら見出しの h
タグを取得して、目次に「is-active」のclassを付与する形で目次のプロパティを操作します。
CSS
JavaScriptのコード量に比べ、CSSのコード量ははそこまで多くありませんが h2
と h3
タグに紐づくプロパティがそれぞれあり、特に before
と after
の擬似要素が多いので注意しましょう。
.toc.js-toc {
display: block;
position: fixed;
left: 10px;
z-index: 999;
background: #FFF;
border: none;
border-radius: 0;
filter: drop-shadow(0px 2px 6px #aaa);
border-radius: 2px;
overflow: hidden;
width: 300px;
top: 50%;
transform: translateY(-50%);
}
/* 見出し */
.toc.js-toc p {
margin: 0;
text-align: center;
background: #f0db3f;
color: #313131;
font-weight: 600;
padding: 8px 0;
letter-spacing: 0.04rem;
font-size: 1.1rem;
position: relative;
line-height: 2.2;
}
.toc.js-toc p:before {
content: "";
position: relative;
width: 40px;
height: 35px;
background: url(https://dubdesign.net/wp-content/uploads/2021/08/book.svg) no-repeat;
display: inline-block;
background-size: cover;
background-position: center;
vertical-align: middle;
margin-right: 8px;
margin-left: -18px;
}
/* 目次の中 */
ul#js-toc-list {
border: none;
list-style: none;
margin: 0;
padding: 13px 25px 18px;
counter-reset: li;
}
ul#js-toc-list li {
position: relative;
padding-left: 2rem;
text-indent: 0rem;
}
ul#js-toc-list li.h2 {
line-height: 1.1;
margin: 0;
}
ul#js-toc-list li.h2 a:before {
position: absolute;
font-weight: 400;
color: #707070;
counter-increment: li;
content: counter(li);
background: #f2f2f2;
border-radius: 9999px;
width: 26px;
height: 26px;
display: inline-block;
text-align: center;
line-height: 26px;
font-size: 0.85rem;
transition: 0.3s ease-in-out;
top: 1px;
text-indent: 0;
left: -2rem;
}
ul#js-toc-list li.h2 a.is-active:before {
background: #f0db3f;
color: #313131;
font-weight: 600;
}
ul#js-toc-list li.h3 {
margin-left: 10px;
padding-left: 1rem;
padding-top: 0;
padding-bottom: 0;
}
ul#js-toc-list li.h3 a:before {
background: none;
font-family: "Font Awesome 5 Free" !important;
font-weight: 900;
color: #f2f2f2;
position: relative;
content: '\f105';
font-size: 1rem;
margin-right: 5px;
transition: 0.1s ease-in-out;
}
ul#js-toc-list li.h3 a.is-active:before {
color: #313131;
}
ul#js-toc-list li.h3:after {
content: "";
width: 2px;
height: 100%;
background: #f2f2f2;
position: absolute;
left: 0;
top: 0;
}
ul#js-toc-list li a {
color: #aaa;
font-size: 1rem;
font-weight: 400;
position: relative;
line-height: 1.6;
vertical-align: text-bottom;
transition: 0.1s ease-in-out;
display: block;
}
ul#js-toc-list li.h3 a {
margin-left: 10px;
font-weight: 400;
display: block;
padding: 7px 0;
}
/* 見出し表示中 */
ul#js-toc-list li a.is-active{
font-weight: 600;
color: #313131;
}
ul#js-toc-list li a:hover {
color: #555;
}
サンプルでは、width:300px;
と比較的小さいスペースに目次を引いているので、目次の文字数が多い場合二行になるので、その時は text-indent
のプロパティを使って改行の開始位置を調整しています。
text-indent
と padding
で相殺しながら改行の開始位置を作る感じです。
個人的に思うのは、改行の開始位置と擬似要素等々複雑に絡んでいるので、デザインをカスタマイズする時は、コピペしたコードを使うよりは、上記のスタイルを全くあてない「サラ」の状態から作って行った方が早いと思います。
さいごに
いかがでしたでしょうか?
この記事では、目次を左に固定表示させていますが、目次の出力もJavaScriptが自動でしてくれるので、2カラムのブログならサイドメニューに置くオブジェクトとしてぴったりだと思います。
是非カスタマイズするなりして使ってみてください。