NuxtでMiller Columns UIを実装する - macOS Finderライクな階層ナビゲーション
結論
Vue 3 Composition APIとNuxt 4で、macOS FinderのカラムビューをイメージしたMiller Columns UIを実装した。最終的な構成は以下の通り。
- 3ペインレイアウト: Miller Columns(左)| 編集フォーム(中央)| 画像プレビュー(右)
- 4カラム階層ナビゲーション: 年度 → 勘定科目 → 月別金額 → 内訳
- キーボードナビゲーション: 左右矢印キーで前後移動
- ループナビゲーション: 最後から次へ進むと最初に戻る
- ソート順: 年度降順 → 勘定科目順 → 月昇順 → 金額降順
背景
税務アプリケーションのOCR Checkerに「ファインダービュー」タブを追加する要件があった。既存の月次推移表は勘定科目×月のマトリックス表示だが、特定の金額をクリックしても内訳(個別のレシート)を確認するUIがなかった。
Miller Columns形式で階層的にドリルダウンし、最終的にレシート画像を表示できるUIを実装することにした。
実装の経緯
1. 初期実装:基本的なMiller Columns
最初に4カラム構成のMiller Columnsを実装した。
// 4カラムの階層構造
const selectedYear = ref<string | null>(null)
const selectedAccount = ref<string | null>(null)
const selectedMonth = ref<string | null>(null)
const selectedReceipt = ref<string | null>(null)
2. 修正1:3ペインレイアウトの追加
ユーザーからのフィードバックで、既存の「スキャンレシート/領収書」タブと同じ編集フォーム(ReceiptForm、AIInfoSection)を追加することになった。
実装後: Miller Columns + 編集フォーム + 画像プレビュー(3ペイン)

<template>
<div class="miller-view">
<!-- 左ペイン: Miller Columns -->
<div class="miller-columns">
<!-- 4カラム -->
</div>
<!-- 中央ペイン: フォーム -->
<div class="form-pane">
<ReceiptForm ... />
<AIInfoSection ... />
</div>
<!-- 右ペイン: 画像プレビュー -->
<div class="image-panel">
...
</div>
</div>
</template>
3. 修正2:選択インジケーターの矢印を削除
選択中のアイテムに「▶」矢印が表示されていたが、背景色だけで選択状態は十分わかるため削除した。
修正前: 選択行に矢印が表示されていた

修正後: 背景色のみで選択状態を表示

<!-- 修正前 -->
<div class="column-item" :class="{ active: item.month === selectedMonth }">
<span>{{ formatMonth(item.month) }}</span>
<span class="amount">{{ formatAmount(item.total) }}</span>
<span v-if="item.month === selectedMonth" class="arrow">▶</span>
</div>
<!-- 修正後 -->
<div class="column-item" :class="{ active: item.month === selectedMonth }">
<span>{{ formatMonth(item.month) }}</span>
<span class="amount">{{ formatAmount(item.total) }}</span>
</div>
4. 修正3:ナビゲーション順序をMiller Columns表示順に統一
矢印キーでの移動がファイル名順になっていたため、Miller Columnsの表示順(年度→勘定科目→月→金額)に合わせた。
// Miller Columnsの表示順でソート
function sortItemsByMillerOrder(items: Receipt[]): Receipt[] {
return [...items].sort((a, b) => {
// 1. 年度(降順:新しい年度が先)
const yearA = (a['日付'] as string)?.slice(0, 4) || '0000'
const yearB = (b['日付'] as string)?.slice(0, 4) || '0000'
if (yearA !== yearB) return yearB.localeCompare(yearA)
// 2. 勘定科目(ACCOUNT_ORDERの順)
const accountA = a['勘定科目'] as string || ''
const accountB = b['勘定科目'] as string || ''
const indexA = ACCOUNT_ORDER.indexOf(accountA)
const indexB = ACCOUNT_ORDER.indexOf(accountB)
const orderA = indexA === -1 ? 999 : indexA
const orderB = indexB === -1 ? 999 : indexB
if (orderA !== orderB) return orderA - orderB
// 3. 月(昇順:1月→12月)
const monthA = (a['日付'] as string)?.slice(5, 7) || '00'
const monthB = (b['日付'] as string)?.slice(5, 7) || '00'
if (monthA !== monthB) return monthA.localeCompare(monthB)
// 4. 金額(降順:大きい金額が先)
const amountA = Number(a['合計支払金額']) || 0
const amountB = Number(b['合計支払金額']) || 0
return amountB - amountA
})
}
5. 修正4:確定件数表示の削除
画像プレビュー上部の「16 / 123 確定」表示が不要だったため削除した。
修正前: ナビゲーションバーに確定件数が表示されていた

修正後: ナビゲーションボタンのみに簡略化

技術的なポイント
Miller Columnsの選択状態を現在のアイテムから導出
全明細をナビゲーションする際、Miller Columnsの選択状態はglobalIndexから計算で導出する。
// 全明細のインデックス
const globalIndex = ref(0)
// 現在選択されているレシート
const currentItem = computed(() => {
if (allItems.value.length === 0) return null
return allItems.value[globalIndex.value] || null
})
// Miller Columns用の選択状態(表示用・計算で導出)
const selectedYear = computed(() => {
const item = currentItem.value
if (!item) return null
const dateStr = item['日付'] as string | undefined
if (!dateStr) return null
const match = dateStr.match(/(\d{4})/)
return match ? match[1] : null
})
ループナビゲーション
最後のアイテムから「次へ」を押すと最初に戻る。
function goNext() {
if (allItems.value.length === 0) return
globalIndex.value = (globalIndex.value + 1) % allItems.value.length
}
function goPrev() {
if (allItems.value.length === 0) return
globalIndex.value = (globalIndex.value - 1 + allItems.value.length) % allItems.value.length
}
キーボードイベントのライフサイクル管理
onMountedでイベントリスナーを登録し、onUnmountedで解除する。
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
e.preventDefault()
goPrev()
}
else if (e.key === 'ArrowRight') {
e.preventDefault()
goNext()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
まとめ
Miller Columns UIの実装で重要なポイントは以下の通り。
- 状態管理: 階層的な選択状態を単一のインデックスから計算で導出すると、一貫性が保てる
- ソート順: ナビゲーション順序と表示順序を統一することでユーザー体験が向上する
- 段階的な改善: ユーザーフィードバックを受けて細かい調整(矢印削除、確定件数削除など)を重ねることで完成度が上がる