開発eurekapu-nuxt4メモ

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でスキップ)