Bootstrap4 のタブ内から他のタブへスクロール位置を調整しつつアンカーリンクで遷移する

Bootstrap4 のタブ内から他のタブへスクロール位置を調整しつつ移動するアンカーリンクを貼ったら、思いの外シビアだったのでメモしておきます。

サンプルコード

例えば以下のようなコード。

<div class="tabWrapper py-3">
    <ul class="nav nav-tabs nav-fill" role="tablist">
        <li class="nav-item" role="presentation">
            <a href="#lorem" id="tabLorem" class="nav-link active" role="tab" data-toggle="tab" aria-controls="lorem" aria-selected="true">Lorem Ipsum</a>
        </li>
        <li class="nav-item" role="presentation">
            <a href="#etaoin" id="tabEtaoin" class="nav-link" role="tab" data-toggle="tab" aria-controls="etaoin" aria-selected="false">etaoin shrdlu</a>
        </li>
        <li class="nav-item" role="presentation">
            <a href="#hujiko" id="tabHujiko" class="nav-link" role="tab" data-toggle="tab" aria-controls="hujiko" aria-selected="false">ふじこ</a>
        </li>
    </ul>
</div>
<div id="tabPanes" class="tab-content">
    <div id="lorem" class="tab-pane show active" role="tabpanel" aria-labelledby="lorem-tab">
        <!-- 略 -->
        <p>他のタブへのアンカーリンクです。<a href="index.html#etaoin" class="tab_link mx-2" data-anchor="etaoin">こちら</a>をクリックしてみてください。</p>
    </div>
    <div id="etaoin" class="tab-pane" role="tabpanel" aria-labelledby="etaoin-tab">
        <!-- 略 -->
        <p>他のタブへのアンカーリンクです。<a href="index.html#hujiko" class="tab_link mx-2" data-anchor="hujiko">こちら</a>をクリックしてみてください。</p>
    </div>
    <div id="hujiko" class="tab-pane" role="tabpanel" aria-labelledby="hujiko-tab">
        <!-- 略 -->
        <p>他のタブへのアンカーリンクです。<a href="index.html#lorem" class="tab_link mx-2" data-anchor="lorem">こちら</a>をクリックしてみてください。</p>
    </div>
</div>

このような感じで各々のタブから他のタブへのアンカーリンクを貼ります。

1つのページの中での遷移なので、 href はダミーとして JS で表示・非表示を制御するようにします。

const tabChange = (screlm) => {
    $('.tab_link').on('click', function (e) {
        e.preventDefault();
        const targetHref = $(this).attr('data-anchor');
        // タブ表示
        $(`.nav-item a[href="#${targetHref}"]`).tab('show');
        return false;
    });
};

$(window).on('load', function () {
    tabChange(screlm);
});

このような感じに。普通に「アンカーリンクのクリックイベントを検出」して「対象タブ要素に .tab('show') でタブ表示を強制」させているだけのシンプルなコードです。

スクロール位置の調整

さて、続いてスクロール位置を調整、つまり「アンカーリンクをクリックしたらタブペインの先頭にスクロール位置が行く(ただしスクロールアニメーションは不要)」ようにしようとしたのですが……。

const scrollElm = () => {
    if ('scrollingElement' in document) {
        return document.scrollingElement;
    }
    if (navigator.userAgent.indexOf('WebKit') !== -1) {
        return document.body;
    }
    return document.documentElement;
};

const tabChange = (screlm) => {
    $('.tab_link').on('click', function (e) {
        e.preventDefault();
        const targetHref = $(this).attr('data-anchor');
        // 表示イベント後にアンカー位置へ瞬間移動
        // shown.bs.tab イベントを拾わないと、非表示のタブは display: none; になっているので .offset().top で要素の位置が正しく取得できない
        const targetTabId = `#tab${targetHref.charAt(0).toUpperCase()}${targetHref.slice(1)}`
        $(targetTabId).on('shown.bs.tab', function () {
            let position = Math.ceil($(`#${targetHref}`).offset().top);
            $(screlm).animate({ scrollTop: position }, 0);
            $(this).off('shown.bs.tab');
        });
        // タブ表示
        $(`.nav-item a[href="#${targetHref}"]`).tab('show');
        return false;
    });
};

$(window).on('load', function () {
    const screlm = scrollElm();
    tabChange(screlm);
});

やろうとしたことは、

  1. 先程と同様、アンカーリンクのクリックイベントを検出して対象タブ要素に .tab('show') でタブ表示を強制
  2. 直後に .offset().top で対象タブペインの位置を取得して0秒のスクロールアニメーションで移動

というもの。

ところが、タブのようにアクティブでない要素に display: none; の指定が付いていると、 .offset().top で要素の正しい位置が取得できません。

これについては、モーダルの中で Slick を動かす場合の諸注意(setPotion指定、 Uncaught TypeError: Cannot read property ‘add’ of null エラー回避のために slick-initialized クラスの確認、 unslick 指定)setPotion の項目で説明したのと同じ現象になります。

また、実行順もわりと大事で、タブ表示→アンカーリンクの移動、の処理の順番では上手く動きませんでした(HTML に fade クラスを付与してフェードアニメーションをさせると、タブの表示が遅れるためアンカーリンクの移動が正しく動くようになる)。

タブ表示→要素の描画がされる→ .offset().top で位置が取得できる→アンカーリンクが作動する、という順番で考えると逆っぽく思えるのですが、そうではない模様。

参考

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。