Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Firefox好きの人間によるタブの実装(HTML/CSS/JS)

お気に入りブラウザFirefox

こんにちは@okuparaです。
自分はプライベートでは専らFirefoxを使っています。 昨今の各社サービスでFirefoxの対応停止のアナウンスを見かける度に、ユーザー数から見ても仕方ないことだと思いながらも、少し寂しい気持ちになっています。

自分的にFirefoxで好きなところの1つがTabです。
ChromeではTabメニューの数が増え続けた時、ギリギリまで小さく表示させて、閾値を超えてしまうとTab一覧の領域からはみ出します。
この際、領域外にTabメニューがまだあるか認知する材料がないので、よく迷うことがあります。

Firefoxでも同じくある程度まで小さくなるとタブ一覧の領域からはみだします。 しかし、送りボタンと微妙な影によって領域の外にタブが存在することをガイドしています。

スクロール領域の端っこまで行けば、送りボタンはdisabledになり影は消えて、これ以上送るタブが存在しないことを認知できます。
また、Tabメニューをスクロールする必要がないウィンドウサイズが確保された場合、送りボタンは消えます。
このようなUI体験は、自分のようにタブを多く開く人間にとって非常に使いやすいです。

ビガップ to Firefox!!!ということで、この記事ではHTML/CSS/JSでこのような挙動のタブの実装に挑戦してみようと思います。 とはいえ全部の挙動を再現しようとすると大変すぎるのでこのエントリーでは一旦以下に絞ります。

  • スクロール位置やスクロール領域の幅によってdisabledや表示が切り替わるタブ送りボタン
  • タブ送りボタンをクリックした時の挙動

他の挙動はまたどこかで続きを書きたいなと思います。
実際のWebアプリケーションのTabのUIでもビューポートによってはTabメニューが収まりきらないこともあるので、その際のUIのパターンの1つとしても参考にもなりそうです。

今回はJSフレームワークやTypeScriptを使わずにCodepenとVanilla JSでプロトタイプしたものを解説をしていきます。

現時点での成果はこちらです。

See the Pen Firefox Style Tab by okupara (@okupara) on CodePen.

CSSフレームワークとしてOpen Propsを使っています。
個人的に気に入っていて、ここ半年くらい個人でUIのプロトタイプをする際にいつも使っています。

送りボタン

Firefoxはこの微妙な影とアクティブなボタンで、領域外にもタブが存在することをガイドしています。
まずはスクロールが両端でない時、ボタンに表示する微妙な影を実装したいです。

この記事のようにoverflow: autoの要素のbackgroundにグラデーションをつけ、background-attachementで制御すれば、CSSだけで影をつけつつもスクロールが両端に来ると消えるということができそうです。

しかしbackgroundで実装しているため、アクティブのTabメニューに背景色をつけて認知しやすくしようとすると、その背景色が影を消してしまいます。
このため今回この方法は断念しました。

色々試行錯誤して、結果的にスクロール領域のラッパーに対してbeforeとafterの擬似要素を使って両端から内側へ向かう透過グラデーションをそれぞれ配置することにしました。

スクロール位置が末端に来た場合に送りボタン自体をdisabledにするため、IntersectionObserverを使う必要があるので、影の表示・非表示もそれに便乗して制御することにします。

 .tablistFrame::before {
   content: "";
   pointer-events: none;
   position: absolute;
   top: 0px;
   left: 0px;
   width: 30px;
   height: 100%;
   background-image: linear-gradient(
     86deg,
     rgba(0, 0, 0, 0.2),
     rgba(0, 0, 0, 0) 6px
   );
   display: var(--display-sahdow-left, none);
 }
 .tablistFrame::after {
   content: "";
   pointer-events: none;
   position: absolute;
   top: 0px;
   right: 0px;
   width: 30px;
   height: 100%;
   background-image: linear-gradient(
     94deg,
     rgba(0, 0, 0, 0) calc(30px - 4px),
     rgba(0, 0, 0, 0.2)
   );
   display: var(--display-sahdow-right, none);
 }
 const intersectionObserver = new IntersectionObserver(detectOnEdges, {
   root: tablist,
   threshold: [0.05, 0.97]
 });
 for (const tabElem of tabs) {
   intersectionObserver.observe(tabElem);
 }
 
 ....
 
 function detectOnEdges(entries) {
   for (const entry of entries) {
     if (entry.target === firstTab) {
       if (entry.intersectionRatio >= 0.97) {
         disableMoveLeftButton();
       } else {
         enableMoveLeftButton();
       }
     if (entry.target === lastTab) {
       if (entry.intersectionRatio >= 0.97) {
         disableMoveRightButton();
       } else {
         enableMoveRightButton();
       }
     }
    ...
 
 function disableMoveRightButton() {
   tablistFrame.style.setProperty("--display-sahdow-right", "none");
   btnMoveRight.setAttribute("disabled", "");
 }
 function enableMoveRightButton() {
   tablistFrame.style.setProperty("--display-sahdow-right", "block");
   btnMoveRight.removeAttribute("disabled");
 }
 function disableMoveLeftButton() {
   tablistFrame.style.setProperty("--display-sahdow-left", "none");
   btnMoveLeft.setAttribute("disabled", "");
 }
 function enableMoveLeftButton() {
   tablistFrame.style.setProperty("--display-sahdow-left", "block");
   btnMoveLeft.removeAttribute("disabled");
 }
 

送りボタンクリック時の挙動

FirefoxのTabでは、例えば今右側に見切れているTabメニューがあった時に、右側の送りボタンをクリックするとその見切れてる要素の次の要素が出現します。 左側の送りボタンも同様です。

先ほどのIntersectionObserverのコールバック内で、スクロール表示領域内で見えてる全ての要素に、data-inviewportというフラグをつけておきます。

// IntersectionObserverコールバック内
...
    if (entry.isIntersecting) {
      entry.target.setAttribute("data-inviewport", "true");
    } else {
      entry.target.removeAttribute("data-inviewport");
    }
...

そして送りボタンをクリックした際にtablist.querySelector([data-inviewport="true"])でユーザーに見えている要素のうちの両端の要素を特定できます。 これにより、次にスクロール領域内に表示したい要素を特定できるので、その要素に対してJSでスクロールします。

領域が十分広い時には送りボタンガイドを非表示にする

FirefoxのUIと同じくタブの領域が広くて、スクロールが必要ない場合は送りボタン自体を表示を消します。 ResizeObserverを使います。

overflow:autoを指定しているラッパーの要素と、スクロール可能領域の幅が同じであればCSSで表示を消します。

const resizeObserver = new ResizeObserver(detectScrollNeeded);
resizeObserver.observe(tablistFrame);

if (entries[0].contentBoxSize[0].inlineSize === tablist.scrollWidth) {
  setGudeButtonsDisplayAttributes("none");
} else {
  setGuideButtonsDisplayAttributes("block");
}

function setGuideButtonsDisplayAttributes(display) {
  btnMoveLeft.style.display = display;
  btnMoveRight.style.display = display;
}

実際にウィンドウを広げて検証すると、ResizeObserver loop limit exceededというエラーが出ますが、このエラーは無視しても問題ないようです

Tabメニューのがアクティブになった時のガタつき

今回、アクティブのメニューは背景色を変え、文字を太くして際立たせたいです。 しかし、Tabメニューごとにwidthが明示されていないと、font-weight: boldfont-weight: normalを切り替える事で文字列全体の幅が変わってしまうため、下記キャプチャのようにガタつきが発生します。

これを防ぐためにこちらからハックを拝借しています。 visibility:hiddenのbefore疑似要素にTabメニューと同じテキストをfont-weight:boldでレンダリングし、高さを0にすることで、予め太字の幅を確保しています。

アクセシビリティ

基本的にはWAI-ARIAのAuthoring Practices Guideを参照しつつ、キーボード操作対応やaria属性の実装をします。
必須項目としてTabメニューで左右の矢印キーが押された場合は隣のTabメニューにフォーカスを移動することが挙げられているので、そのように実装します。

Tabメニューの部分はそれで良さそうですが、送りボタンはどうすれば良いでしょうか?
ポイントは、スクロールが両端に来た場合、送りボタンをdisabledにする実装を入れていることです。
Heydon Pickering氏はInclusive Componentsという本の中で、このケースと似たような性質を持つカルーセルの送りボタンの実装を紹介しています。
そこで、ボタンが動的にdisabledに切り替わることについての問題を指摘しています。

disableのボタンにはフォーカスする事ができません。もしユーザーがフォーカスしているボタンが状態に応じてdisableに変わると混乱のもととなるでしょう。ユーザーのフォーカスはどこかへ行き、タブバックで戻ろうとしてももうそのボタンにはフォーカスを移せません。

Heydon氏はこの問題について何が良い解決策かは、コンテキストやアプリケーションでのUIの要件によって異なってくるため、しっかりとUIを評価して決めるように促しています。

個人的には今回の実装では、送りボタンにはフォーカスしなくても良いと思っています。 Tabメニュー上での矢印キーで十分に同等の体験が得られているからです。
実際にFirefoxでもタブキーでフォーカスを移動すると、Tabメニューにはフォーカスが行きますが、送りボタンには行きません。
ということで、ボタンにはtabindex=-1aria-hidden="true"をセットしておくことにします。

最後に

一旦、今回はここまでにしたいなと思います。 冒頭にも書きました通り、FirefoxのタブUIは他にもまだナイスな挙動があります。

  • ウィンドウをリサイズしたとき、選択中のタブが領域外へ出ないようになっている
  • 送りボタンへのクリックが長押し、素早く2回、素早く3回で違った挙動をする

どこかでこの辺りも網羅した実装チャレンジをしたいと思っています。

では!