静的サイトにエディタのようなタブを作る。sessionStorageとハイドレーションの扱い
本記事にはアフィリエイト広告(PR)を含みます。詳しくは広告・アフィリエイトについてをご確認ください。
このサイトの上部には、エディタのようなタブが並んでいます。ページを開くとタブが増え、タブを押すとそのページに移り、× で閉じられます。この記事では、Next.js で静的に書き出したサイトで、このタブをどう作ったかを書きます。
サーバーを持たない静的サイトなので、開いているタブの一覧はブラウザに保存するしかありません。そのときに気をつけたのが、ハイドレーション(サーバーで作ったHTMLに、ブラウザで JavaScript の動きを結び付ける処理)とのずれです。
タブに求めた動き
最初に、タブの動きを決めました。
- ページを開くと、そのページのタブが右端に増える。すでに開いていれば増やさない
- ページを移動しても、開いたタブは残る
- 表示中のタブを閉じたら、右隣のタブ(なければ左隣)のページに移る
- 最後の1枚を閉じたら、何も開いていない画面に移る
一覧は sessionStorage に保存する
開いているタブの一覧は、sessionStorage に保存しています。localStorage と違って、ブラウザのタブを閉じれば消えます。エディタの「今の作業状態」に近い持ち方で、次に来たときに前回のタブがずらっと残っている、ということもありません。
保存できない環境(プライベートブラウズなど)でも壊れないように、読み書きは try で囲み、失敗したら空の一覧として扱います。この場合は、ページを移動するたびに今のページのタブだけが表示されます。
開く・閉じるは純粋な関数にする
タブを開く処理と閉じる処理は、一覧を受け取って新しい一覧を返す関数にしました。画面やストレージに触らないので、動きを確かめやすくなります。
export type OpenTab = { href: string; label: string };
/**
* タブを閉じる。閉じたあとに表示すべきタブ(右隣、なければ左隣)も返す。
* 最後の1枚を閉じた場合、next は null になる
*/
export const withTabClosed = (tabs: OpenTab[], href: string) => {
const index = tabs.findIndex((candidate) => candidate.href === href);
const rest = tabs.filter((candidate) => candidate.href !== href);
const next = rest[Math.min(Math.max(index, 0), rest.length - 1)] ?? null;
return { tabs: rest, next };
};
閉じたタブがあった位置の番号で、残りの一覧を引くのがポイントです。閉じたタブの右隣にあったタブが、その番号に詰めてくるからです。いちばん右のタブを閉じたときは番号がはみ出すので、最後の1枚(左隣)に寄せています。
開く処理では、開いておけるタブを8枚までにしました。超えたら、今のページ以外でいちばん古いタブから閉じます。
ハイドレーションとずれないようにする
ここが一番気をつけたところです。
sessionStorage はブラウザにしかありません。静的に書き出すときにはサーバー側で HTML を作るので、そのときには保存済みの一覧を読めません。もし最初の描画で sessionStorage を読もうとすると、サーバーで作った HTML とブラウザで作った画面が食い違い、ハイドレーションのエラーになります。
そこで、最初の描画では「今のページのタブ1枚だけ」を出し、ブラウザで読み込んだあとに、保存済みの一覧を足すようにしました。
const [tabs, setTabs] = useState<OpenTab[]>([current]);
useEffect(() => {
const opened = withTabOpened(readOpenTabs(), current);
setTabs(opened);
writeOpenTabs(opened);
}, [current.href, current.label]);
サーバーで作る HTML にも、ブラウザでの最初の描画にも、今のページのタブだけが入ります。両者が一致するので、ハイドレーションのエラーは起きません。読み込みの直後に残りのタブが現れますが、今のページのタブは最初から出ているので、見た目のちらつきもほとんど気になりません。
閉じたら移動する
× を押したときは、一覧から外して保存し、閉じたのが表示中のタブなら、次のタブのページに移動します。
const handleClose = (href: string) => {
const { tabs: rest, next } = withTabClosed(tabs, href);
setTabs(rest);
writeOpenTabs(rest);
if (href === current.href) router.push(next?.href ?? "/welcome");
};
移動先のページは、自分のタブを開く処理をもう一度実行します。すでに一覧に入っているので、タブは増えずに、そのタブが選択された状態になります。最後の1枚を閉じたときは、何も開いていない画面(/welcome)に移します。このページは検索結果に出す意味がないので、noindex にしています。
見た目と操作で気をつけたこと
リンクの中にボタンを入れない
タブ全体をリンクにして、その中に × のボタンを入れたくなります。ただ、リンクの中にボタンを入れるのは、HTML としては正しくない書き方です。タブは div で囲み、その中にリンクと × のボタンを並べて置いています。
× はマウスを乗せたときに出す
VS Code と同じように、× は選択中のタブと、マウスを乗せたタブにだけ出しています。ただ、スマホにはマウスを乗せるという操作がありません。そこで、@media (hover: none) のときは常に × を出すようにしました。
表示中のタブが見える位置までスクロールする
タブが増えると、タブの列は横にスクロールします。ページを移ったときに、表示中のタブが画面の外に隠れていると分かりにくいので、scrollIntoView で見える位置まで動かしています。