• #Nuxt
  • #Vue
  • #UI
  • #Miller Columns
  • #フロントエンド
開発

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ペイン)

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の実装で重要なポイントは以下の通り。

  1. 状態管理: 階層的な選択状態を単一のインデックスから計算で導出すると、一貫性が保てる
  2. ソート順: ナビゲーション順序と表示順序を統一することでユーザー体験が向上する
  3. 段階的な改善: ユーザーフィードバックを受けて細かい調整(矢印削除、確定件数削除など)を重ねることで完成度が上がる