MillerViewerのサイドバー折りたたみとTopicPager — 共通コンポーネントを1回直すと80ページが一斉に変わる
縦置きモニター(1080×1920)で開いた画面を見たユーザーから「メインコンテンツが狭い」と一言飛んできた。MillerViewer.vueに折りたたみボタンを2つ足したら、簿記3級・CFS・Excel・Excelショートカット・会計入門の5系統、80以上のページが一斉に新UIへ切り替わった。共通コンポーネントを直したのは1ファイルだけ。
発端
縦置きモニターの幅は1080px。MillerViewerはセクション列・チャプター列・メインコンテンツ列の3列構成で、左の2列に固定幅(200px + 200px)を取られると、メインコンテンツに残るのは600px強。教科書的な文章をこの幅で読むには窮屈で、ユーザーから「左の2列を畳めるようにしたい」という要望が来た。
計画書だけ作って実装に突入
普段は計画を立てたらCodexにレビューを通すが、この日はrate limitで叩けなかった。計画書(memo/2026-05-04/miller-sidebar-collapse-plan.md)だけ作って、そのまま実装に入った。仕様は以下のとおり。
- セクション列の上端に折りたたみボタンを置く(クリックで列幅を0にする)
- チャプター列の上端にも折りたたみボタンを置く
- 折りたたみ状態はlocalStorageに保存する
- ページ遷移しても状態が残る
実装1: グリッドテンプレートを動的に切り替える
MillerViewerはCSS Gridで列幅を固定していた。
.miller-viewer {
display: grid;
grid-template-columns: 200px 200px 1fr;
}
折りたたみ状態を反映するため、grid-template-columnsをcomputed化した。
const gridTemplate = computed(() => {
const major = collapsedMajor.value ? '0px' : '200px'
const chapter = collapsedChapter.value ? '0px' : '200px'
return `${major} ${chapter} 1fr`
})
スタイルバインディングで:style="{ gridTemplateColumns: gridTemplate }"と書き換え、ボタンを押すと列が0pxに潰れる挙動を実現した。
実装2: localStorageで状態を持つ — 最初に踏んだ罠
最初はキーを次のように書いた。
const storageKey = `millerIdx:${route.path}:collapsedMajor`
ルートごとに状態を分けたほうが親切だろう、と考えた結果だ。実際に開発サーバーで動かしてみると、ボタンは動く。リロードしても状態は残る。一見問題なさそうに見えた。
ところが次のページに遷移した瞬間、サイドバーが元の幅に戻った。「畳んだまま読み続けたい」という当初の要望と真逆の挙動になっている。
ルートが変わるとキーも変わるので、新しいページのlocalStorageキーには値が入っていない。だから初期値(折りたたみ解除)に戻る。仕様としては正しいが、ユーザーが期待しているのは「一度畳んだらサイト全体で畳まれた状態が続く」ことだった。
キーをグローバル化した。
// Before: ルート依存
const storageKey = `millerIdx:${route.path}:collapsedMajor`
// After: グローバル
const storageKey = 'miller:collapsedMajor'
これで全ページが同じ状態を共有するようになった。
実装3: TopicPagerを82ページに展開
ついでに前後ページへのリンクも欲しい、という要望が出た。case100(簿記3級の100問演習)は82ページあり、1ページずつ手で次のページに飛ぶのが面倒だった。
TopicPagerコンポーネントを作り、ページ下部に「前のページ」「次のページ」リンクを置いた。さらにキーボード操作を加えた。
onMounted(() => {
window.addEventListener('keydown', handleKey)
})
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' && prevPath.value) navigateTo(prevPath.value)
if (e.key === 'ArrowRight' && nextPath.value) navigateTo(nextPath.value)
}
矢印キーで前後のページを行き来できるようにした。82ページを連続で確認するときに、マウスを使わず読み進められる。
罠2: 矢印キーで「次のページに行ったらサイドバーが開いてしまう」
TopicPagerを入れた直後、ユーザーから報告が来た。「矢印キーで次のページに行くと、畳んだはずのサイドバーが開いてる」。
実装1で書いた折りたたみ機能を入れたあと、しばらくはマウスでチャプター列のリンクを踏んで遷移していた。マウス遷移だと違和感に気付かなかった。矢印キーで連続遷移したことで、初めて「ページ遷移するたびに状態がリセットされる」バグが顕在化した。
これがまさに実装2で書いたlocalStorageキーのルート依存問題だった。矢印キーショートカットを入れたおかげで、隠れていたバグが浮かんだ。
修正は1行。route.pathをキーから外してグローバルキーに統一した。同じ修正でセクション列・チャプター列の両方が直った。
共通コンポーネントの強み — 1回直すと5系統に展開する
MillerViewerは以下の5系統で使われている。
- 簿記3級(boki3)
- CFS(cf-statement)
- Excel基礎講座
- Excelショートカット集
- 会計入門
ページ数を数えると、合計で80以上ある。今回の折りたたみ機能は、MillerViewer.vue 1ファイルにボタンとcomputed gridTemplateを足しただけで、5系統すべてに自動的に反映された。各系統のページファイルは1行も触っていない。
TopicPagerはcase100の82ページに別途組み込みが必要だったが、こちらもコンポーネント1個を呼び出すだけで全ページに展開できた。
税理士・会計士業務でも同じ構図がある。税務マニュアルテンプレートのヘッダー部分を1箇所直すと、それを使っている全顧問先の月次報告書が一斉に最新化される。共通テンプレートを直す習慣がある事務所と、案件ごとにコピーして個別編集する事務所では、3年後の保守コストが桁で変わる。
学び
- 新しい操作系を入れるとバグが浮く — 矢印キーで連続遷移しなければ、サイドバーが開く問題は気付かなかった。同じ動作を繰り返すと、隠れていた状態管理バグが顕在化する
- localStorageキーはスコープ設計が先 — 「ルートごとに分けたほうが丁寧」と考えてキーに
route.pathを入れたが、ユーザーが欲しかったのは「サイト全体で1つの状態」だった。設計前に「誰が、どのスコープで状態を共有するか」を一言ユーザーに確認すべきだった - 共通コンポーネントを直す費用対効果は高い — 80ページに手動でボタンを足すと80回のコミットになる。1ファイルで済めば、レビューもテストも1回で終わる
- Codexが叩けない日は計画書だけでも書く — 計画書を書く工程で、grid templateをcomputedにする方針とlocalStorageでの状態保持が頭の中で整理できた。実装中に迷わなかった
関連ファイル
apps/web/app/components/MillerViewer.vue— 折りたたみボタンとgridTemplate computedを追加apps/web/app/components/TopicPager.vue— 前後リンク + 矢印キーショートカットmemo/2026-05-04/miller-sidebar-collapse-plan.md— 計画書(Codexレビューはrate limitでスキップ)