[{"data":1,"prerenderedAt":752},["ShallowReactive",2],{"content-/miller-collapsible-sidebar-and-pager":3,"all-pages-for-dir":750,"og-image-/miller-collapsible-sidebar-and-pager":751},{"id":4,"title":5,"body":6,"category":729,"description":730,"extension":731,"meta":732,"navigation":385,"ogImage":733,"path":734,"project_name":735,"published":736,"publishedAt":737,"seo":738,"stem":739,"tags":740,"todo":748,"unpublished":736,"updatedAt":733,"__hash__":749},"pages/2026-05/2026-05-04/miller-collapsible-sidebar-and-pager.md","MillerViewerのサイドバー折りたたみとTopicPagerをVueで実装 — 共通コンポーネント1個追加で5系統80ページに自動展開",{"type":7,"value":8,"toc":718},"minimark",[9,14,18,22,25,28,36,52,56,59,135,142,282,289,293,296,332,335,338,341,344,408,411,415,418,421,603,606,610,613,616,619,626,630,633,650,653,656,659,662,692,695,714],[10,11,13],"h1",{"id":12},"millerviewerのサイドバー折りたたみとtopicpager-共通コンポーネントを1回直すと80ページが一斉に変わる","MillerViewerのサイドバー折りたたみとTopicPager — 共通コンポーネントを1回直すと80ページが一斉に変わる",[15,16,17],"p",{},"縦置きモニター（1080×1920）で開いた画面を見たユーザーから「メインコンテンツが狭い」と一言飛んできた。MillerViewer.vueに折りたたみボタンを2つ足したら、簿記3級・CFS・Excel・Excelショートカット・会計入門の5系統、80以上のページが一斉に新UIへ切り替わった。共通コンポーネントを直したのは1ファイルだけ。",[19,20,21],"h2",{"id":21},"発端",[15,23,24],{},"縦置きモニターの幅は1080px。MillerViewerはセクション列・チャプター列・メインコンテンツ列の3列構成で、左の2列に固定幅（200px + 200px）を取られると、メインコンテンツに残るのは600px強。教科書的な文章をこの幅で読むには窮屈で、ユーザーから「左の2列を畳めるようにしたい」という要望が来た。",[19,26,27],{"id":27},"計画書だけ作って実装に突入",[15,29,30,31,35],{},"普段は計画を立てたらCodexにレビューを通すが、この日はrate limitで叩けなかった。計画書（",[32,33,34],"code",{},"memo/2026-05-04/miller-sidebar-collapse-plan.md","）だけ作って、そのまま実装に入った。仕様は以下のとおり。",[37,38,39,43,46,49],"ul",{},[40,41,42],"li",{},"セクション列の上端に折りたたみボタンを置く（クリックで列幅を0にする）",[40,44,45],{},"チャプター列の上端にも折りたたみボタンを置く",[40,47,48],{},"折りたたみ状態はlocalStorageに保存する",[40,50,51],{},"ページ遷移しても状態が残る",[19,53,55],{"id":54},"実装1-グリッドテンプレートを動的に切り替える","実装1: グリッドテンプレートを動的に切り替える",[15,57,58],{},"MillerViewerはCSS Gridで列幅を固定していた。",[60,61,66],"pre",{"className":62,"code":63,"language":64,"meta":65,"style":65},"language-css shiki shiki-themes vitesse-light vitesse-light",".miller-viewer {\n  display: grid;\n  grid-template-columns: 200px 200px 1fr;\n}\n","css","",[32,67,68,84,101,129],{"__ignoreMap":65},[69,70,73,77,81],"span",{"class":71,"line":72},"line",1,[69,74,76],{"class":75},"shFtX",".",[69,78,80],{"class":79},"s4oTP","miller-viewer",[69,82,83],{"class":75}," {\n",[69,85,87,91,94,98],{"class":71,"line":86},2,[69,88,90],{"class":89},"sz8Xr","  display",[69,92,93],{"class":75},":",[69,95,97],{"class":96},"snbK4"," grid",[69,99,100],{"class":75},";\n",[69,102,104,107,109,113,117,119,121,124,127],{"class":71,"line":103},3,[69,105,106],{"class":89},"  grid-template-columns",[69,108,93],{"class":75},[69,110,112],{"class":111},"sM54T"," 200",[69,114,116],{"class":115},"stQ0i","px",[69,118,112],{"class":111},[69,120,116],{"class":115},[69,122,123],{"class":111}," 1",[69,125,126],{"class":115},"fr",[69,128,100],{"class":75},[69,130,132],{"class":71,"line":131},4,[69,133,134],{"class":75},"}\n",[15,136,137,138,141],{},"折りたたみ状態を反映するため、",[32,139,140],{},"grid-template-columns","をcomputed化した。",[60,143,147],{"className":144,"code":145,"language":146,"meta":65,"style":65},"language-typescript shiki shiki-themes vitesse-light vitesse-light","const gridTemplate = computed(() => {\n  const major = collapsedMajor.value ? '0px' : '200px'\n  const chapter = collapsedChapter.value ? '0px' : '200px'\n  return `${major} ${chapter} 1fr`\n})\n","typescript",[32,148,149,172,214,246,276],{"__ignoreMap":65},[69,150,151,154,157,160,164,167,170],{"class":71,"line":72},[69,152,153],{"class":115},"const ",[69,155,156],{"class":79},"gridTemplate",[69,158,159],{"class":75}," =",[69,161,163],{"class":162},"senZ8"," computed",[69,165,166],{"class":75},"(()",[69,168,169],{"class":75}," =>",[69,171,83],{"class":75},[69,173,174,177,180,182,185,187,190,193,197,201,203,206,208,211],{"class":71,"line":86},[69,175,176],{"class":115},"  const ",[69,178,179],{"class":79},"major",[69,181,159],{"class":75},[69,183,184],{"class":79}," collapsedMajor",[69,186,76],{"class":75},[69,188,189],{"class":79},"value",[69,191,192],{"class":115}," ? ",[69,194,196],{"class":195},"sMJiu","'",[69,198,200],{"class":199},"sdGka","0px",[69,202,196],{"class":195},[69,204,205],{"class":115}," : ",[69,207,196],{"class":195},[69,209,210],{"class":199},"200px",[69,212,213],{"class":195},"'\n",[69,215,216,218,221,223,226,228,230,232,234,236,238,240,242,244],{"class":71,"line":103},[69,217,176],{"class":115},[69,219,220],{"class":79},"chapter",[69,222,159],{"class":75},[69,224,225],{"class":79}," collapsedChapter",[69,227,76],{"class":75},[69,229,189],{"class":79},[69,231,192],{"class":115},[69,233,196],{"class":195},[69,235,200],{"class":199},[69,237,196],{"class":195},[69,239,205],{"class":115},[69,241,196],{"class":195},[69,243,210],{"class":199},[69,245,213],{"class":195},[69,247,248,252,255,258,260,263,266,268,270,273],{"class":71,"line":131},[69,249,251],{"class":250},"sHkkW","  return",[69,253,254],{"class":195}," `",[69,256,257],{"class":250},"${",[69,259,179],{"class":199},[69,261,262],{"class":250},"}",[69,264,265],{"class":250}," ${",[69,267,220],{"class":199},[69,269,262],{"class":250},[69,271,272],{"class":199}," 1fr",[69,274,275],{"class":195},"`\n",[69,277,279],{"class":71,"line":278},5,[69,280,281],{"class":75},"})\n",[15,283,284,285,288],{},"スタイルバインディングで",[32,286,287],{},":style=\"{ gridTemplateColumns: gridTemplate }\"","と書き換え、ボタンを押すと列が0pxに潰れる挙動を実現した。",[19,290,292],{"id":291},"実装2-localstorageで状態を持つ-最初に踏んだ罠","実装2: localStorageで状態を持つ — 最初に踏んだ罠",[15,294,295],{},"最初はキーを次のように書いた。",[60,297,299],{"className":144,"code":298,"language":146,"meta":65,"style":65},"const storageKey = `millerIdx:${route.path}:collapsedMajor`\n",[32,300,301],{"__ignoreMap":65},[69,302,303,305,308,310,312,315,317,320,322,325,327,330],{"class":71,"line":72},[69,304,153],{"class":115},[69,306,307],{"class":79},"storageKey",[69,309,159],{"class":75},[69,311,254],{"class":195},[69,313,314],{"class":199},"millerIdx:",[69,316,257],{"class":250},[69,318,319],{"class":199},"route",[69,321,76],{"class":75},[69,323,324],{"class":199},"path",[69,326,262],{"class":250},[69,328,329],{"class":199},":collapsedMajor",[69,331,275],{"class":195},[15,333,334],{},"ルートごとに状態を分けたほうが親切だろう、と考えた結果だ。実際に開発サーバーで動かしてみると、ボタンは動く。リロードしても状態は残る。一見問題なさそうに見えた。",[15,336,337],{},"ところが次のページに遷移した瞬間、サイドバーが元の幅に戻った。「畳んだまま読み続けたい」という当初の要望と真逆の挙動になっている。",[15,339,340],{},"ルートが変わるとキーも変わるので、新しいページのlocalStorageキーには値が入っていない。だから初期値（折りたたみ解除）に戻る。仕様としては正しいが、ユーザーが期待しているのは「一度畳んだらサイト全体で畳まれた状態が続く」ことだった。",[15,342,343],{},"キーをグローバル化した。",[60,345,347],{"className":144,"code":346,"language":146,"meta":65,"style":65},"// Before: ルート依存\nconst storageKey = `millerIdx:${route.path}:collapsedMajor`\n\n// After: グローバル\nconst storageKey = 'miller:collapsedMajor'\n",[32,348,349,355,381,387,392],{"__ignoreMap":65},[69,350,351],{"class":71,"line":72},[69,352,354],{"class":353},"sxvE3","// Before: ルート依存\n",[69,356,357,359,361,363,365,367,369,371,373,375,377,379],{"class":71,"line":86},[69,358,153],{"class":115},[69,360,307],{"class":79},[69,362,159],{"class":75},[69,364,254],{"class":195},[69,366,314],{"class":199},[69,368,257],{"class":250},[69,370,319],{"class":199},[69,372,76],{"class":75},[69,374,324],{"class":199},[69,376,262],{"class":250},[69,378,329],{"class":199},[69,380,275],{"class":195},[69,382,383],{"class":71,"line":103},[69,384,386],{"emptyLinePlaceholder":385},true,"\n",[69,388,389],{"class":71,"line":131},[69,390,391],{"class":353},"// After: グローバル\n",[69,393,394,396,398,400,403,406],{"class":71,"line":278},[69,395,153],{"class":115},[69,397,307],{"class":79},[69,399,159],{"class":75},[69,401,402],{"class":195}," '",[69,404,405],{"class":199},"miller:collapsedMajor",[69,407,213],{"class":195},[15,409,410],{},"これで全ページが同じ状態を共有するようになった。",[19,412,414],{"id":413},"実装3-topicpagerを82ページに展開","実装3: TopicPagerを82ページに展開",[15,416,417],{},"ついでに前後ページへのリンクも欲しい、という要望が出た。case100（簿記3級の100問演習）は82ページあり、1ページずつ手で次のページに飛ぶのが面倒だった。",[15,419,420],{},"TopicPagerコンポーネントを作り、ページ下部に「前のページ」「次のページ」リンクを置いた。さらにキーボード操作を加えた。",[60,422,424],{"className":144,"code":423,"language":146,"meta":65,"style":65},"onMounted(() => {\n  window.addEventListener('keydown', handleKey)\n})\n\nconst handleKey = (e: KeyboardEvent) => {\n  if (e.key === 'ArrowLeft' && prevPath.value) navigateTo(prevPath.value)\n  if (e.key === 'ArrowRight' && nextPath.value) navigateTo(nextPath.value)\n}\n",[32,425,426,437,466,470,474,503,553,598],{"__ignoreMap":65},[69,427,428,431,433,435],{"class":71,"line":72},[69,429,430],{"class":162},"onMounted",[69,432,166],{"class":75},[69,434,169],{"class":75},[69,436,83],{"class":75},[69,438,439,442,444,447,450,452,455,457,460,463],{"class":71,"line":86},[69,440,441],{"class":79},"  window",[69,443,76],{"class":75},[69,445,446],{"class":162},"addEventListener",[69,448,449],{"class":75},"(",[69,451,196],{"class":195},[69,453,454],{"class":199},"keydown",[69,456,196],{"class":195},[69,458,459],{"class":75},",",[69,461,462],{"class":79}," handleKey",[69,464,465],{"class":75},")\n",[69,467,468],{"class":71,"line":103},[69,469,281],{"class":75},[69,471,472],{"class":71,"line":131},[69,473,386],{"emptyLinePlaceholder":385},[69,475,476,478,481,483,486,489,492,496,499,501],{"class":71,"line":278},[69,477,153],{"class":115},[69,479,480],{"class":162},"handleKey",[69,482,159],{"class":75},[69,484,485],{"class":75}," (",[69,487,488],{"class":79},"e",[69,490,491],{"class":75},": ",[69,493,495],{"class":494},"sSkh3","KeyboardEvent",[69,497,498],{"class":75},")",[69,500,169],{"class":75},[69,502,83],{"class":75},[69,504,506,509,511,513,515,518,521,523,526,528,531,534,536,538,540,543,545,547,549,551],{"class":71,"line":505},6,[69,507,508],{"class":250},"  if",[69,510,485],{"class":75},[69,512,488],{"class":79},[69,514,76],{"class":75},[69,516,517],{"class":79},"key",[69,519,520],{"class":115}," === ",[69,522,196],{"class":195},[69,524,525],{"class":199},"ArrowLeft",[69,527,196],{"class":195},[69,529,530],{"class":115}," && ",[69,532,533],{"class":79},"prevPath",[69,535,76],{"class":75},[69,537,189],{"class":79},[69,539,498],{"class":75},[69,541,542],{"class":162}," navigateTo",[69,544,449],{"class":75},[69,546,533],{"class":79},[69,548,76],{"class":75},[69,550,189],{"class":79},[69,552,465],{"class":75},[69,554,556,558,560,562,564,566,568,570,573,575,577,580,582,584,586,588,590,592,594,596],{"class":71,"line":555},7,[69,557,508],{"class":250},[69,559,485],{"class":75},[69,561,488],{"class":79},[69,563,76],{"class":75},[69,565,517],{"class":79},[69,567,520],{"class":115},[69,569,196],{"class":195},[69,571,572],{"class":199},"ArrowRight",[69,574,196],{"class":195},[69,576,530],{"class":115},[69,578,579],{"class":79},"nextPath",[69,581,76],{"class":75},[69,583,189],{"class":79},[69,585,498],{"class":75},[69,587,542],{"class":162},[69,589,449],{"class":75},[69,591,579],{"class":79},[69,593,76],{"class":75},[69,595,189],{"class":79},[69,597,465],{"class":75},[69,599,601],{"class":71,"line":600},8,[69,602,134],{"class":75},[15,604,605],{},"矢印キーで前後のページを行き来できるようにした。82ページを連続で確認するときに、マウスを使わず読み進められる。",[19,607,609],{"id":608},"罠2-矢印キーで次のページに行ったらサイドバーが開いてしまう","罠2: 矢印キーで「次のページに行ったらサイドバーが開いてしまう」",[15,611,612],{},"TopicPagerを入れた直後、ユーザーから報告が来た。「矢印キーで次のページに行くと、畳んだはずのサイドバーが開いてる」。",[15,614,615],{},"実装1で書いた折りたたみ機能を入れたあと、しばらくはマウスでチャプター列のリンクを踏んで遷移していた。マウス遷移だと違和感に気付かなかった。矢印キーで連続遷移したことで、初めて「ページ遷移するたびに状態がリセットされる」バグが顕在化した。",[15,617,618],{},"これがまさに実装2で書いたlocalStorageキーのルート依存問題だった。矢印キーショートカットを入れたおかげで、隠れていたバグが浮かんだ。",[15,620,621,622,625],{},"修正は1行。",[32,623,624],{},"route.path","をキーから外してグローバルキーに統一した。同じ修正でセクション列・チャプター列の両方が直った。",[19,627,629],{"id":628},"共通コンポーネントの強み-1回直すと5系統に展開する","共通コンポーネントの強み — 1回直すと5系統に展開する",[15,631,632],{},"MillerViewerは以下の5系統で使われている。",[37,634,635,638,641,644,647],{},[40,636,637],{},"簿記3級（boki3）",[40,639,640],{},"CFS（cf-statement）",[40,642,643],{},"Excel基礎講座",[40,645,646],{},"Excelショートカット集",[40,648,649],{},"会計入門",[15,651,652],{},"ページ数を数えると、合計で80以上ある。今回の折りたたみ機能は、MillerViewer.vue 1ファイルにボタンとcomputed gridTemplateを足しただけで、5系統すべてに自動的に反映された。各系統のページファイルは1行も触っていない。",[15,654,655],{},"TopicPagerはcase100の82ページに別途組み込みが必要だったが、こちらもコンポーネント1個を呼び出すだけで全ページに展開できた。",[15,657,658],{},"税理士・会計士業務でも同じ構図がある。税務マニュアルテンプレートのヘッダー部分を1箇所直すと、それを使っている全顧問先の月次報告書が一斉に最新化される。共通テンプレートを直す習慣がある事務所と、案件ごとにコピーして個別編集する事務所では、3年後の保守コストが桁で変わる。",[19,660,661],{"id":661},"学び",[37,663,664,671,680,686],{},[40,665,666,670],{},[667,668,669],"strong",{},"新しい操作系を入れるとバグが浮く"," — 矢印キーで連続遷移しなければ、サイドバーが開く問題は気付かなかった。同じ動作を繰り返すと、隠れていた状態管理バグが顕在化する",[40,672,673,676,677,679],{},[667,674,675],{},"localStorageキーはスコープ設計が先"," — 「ルートごとに分けたほうが丁寧」と考えてキーに",[32,678,624],{},"を入れたが、ユーザーが欲しかったのは「サイト全体で1つの状態」だった。設計前に「誰が、どのスコープで状態を共有するか」を一言ユーザーに確認すべきだった",[40,681,682,685],{},[667,683,684],{},"共通コンポーネントを直す費用対効果は高い"," — 80ページに手動でボタンを足すと80回のコミットになる。1ファイルで済めば、レビューもテストも1回で終わる",[40,687,688,691],{},[667,689,690],{},"Codexが叩けない日は計画書だけでも書く"," — 計画書を書く工程で、grid templateをcomputedにする方針とlocalStorageでの状態保持が頭の中で整理できた。実装中に迷わなかった",[19,693,694],{"id":694},"関連ファイル",[37,696,697,703,709],{},[40,698,699,702],{},[32,700,701],{},"apps/web/app/components/MillerViewer.vue"," — 折りたたみボタンとgridTemplate computedを追加",[40,704,705,708],{},[32,706,707],{},"apps/web/app/components/TopicPager.vue"," — 前後リンク + 矢印キーショートカット",[40,710,711,713],{},[32,712,34],{}," — 計画書（Codexレビューはrate limitでスキップ）",[715,716,717],"style",{},"html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}html pre.shiki code .snbK4, html code.shiki .snbK4{--shiki-default:#A65E2B;--shiki-dark:#A65E2B}html pre.shiki code .sM54T, html code.shiki .sM54T{--shiki-default:#2F798A;--shiki-dark:#2F798A}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .sSkh3, html code.shiki .sSkh3{--shiki-default:#2E8F82;--shiki-dark:#2E8F82}",{"title":65,"searchDepth":86,"depth":86,"links":719},[720,721,722,723,724,725,726,727,728],{"id":21,"depth":86,"text":21},{"id":27,"depth":86,"text":27},{"id":54,"depth":86,"text":55},{"id":291,"depth":86,"text":292},{"id":413,"depth":86,"text":414},{"id":608,"depth":86,"text":609},{"id":628,"depth":86,"text":629},{"id":661,"depth":86,"text":661},{"id":694,"depth":86,"text":694},"dev","縦置きモニター（1080×1920）でメインコンテンツが狭いというユーザーの指摘から、MillerViewer.vueにセクション・チャプターのサイドバー折りたたみボタンを追加。localStorageキーをルート依存からグローバル化したバグ修正、TopicPager（前後リンク + 矢印キー操作）を82ページに展開した1日の記録","md",{},null,"/miller-collapsible-sidebar-and-pager","eurekapu-nuxt4",false,"2026-05-04T00:00:00.000Z",{"title":5,"description":730},"2026-05/2026-05-04/miller-collapsible-sidebar-and-pager",[741,742,743,744,745,746,747],"MillerViewer","サイドバー","折りたたみ","ページネーション","Vue","localStorage","共通コンポーネント","memo","xneC3Nga64U3JBw-lwS5DhgBmynDDMtigDXE6DvX1-w",[],"https://log.eurekapu.com/og/blog/miller-collapsible-sidebar-and-pager.png?v=2026-05-04T00%3A00%3A00.000Z&title=MillerViewer%E3%81%AE%E3%82%B5%E3%82%A4%E3%83%89%E3%83%90%E3%83%BC%E6%8A%98%E3%82%8A%E3%81%9F%E3%81%9F%E3%81%BF%E3%81%A8TopicPager%E3%82%92Vue%E3%81%A7%E5%AE%9F%E8%A3%85%20%E2%80%94%20%E5%85%B1%E9%80%9A%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%881%E5%80%8B%E8%BF%BD%E5%8A%A0%E3%81%A75%E7%B3%BB%E7%B5%B180%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E8%87%AA%E5%8B%95%E5%B1%95%E9%96%8B&author=Kei%20Komatsu&sig=4d13d9f97d36e48d",1782528832896]