• #Vue.js
  • #Nuxt
  • #コンポーネント設計
  • #リファクタリング
開発tax-assistant完了

Vue.jsで帳票画像ナビゲーションUIを共通コンポーネント化する

結論

複数のタブで重複していた「帳票画像」のナビゲーションUI(前へ/次へボタン、位置表示)を ImageNavigationPanel.vue という共通コンポーネントに切り出した。4つのタブすべてで同じUIが使われるようになり、保守性が向上した。

問題の背景

税務アシスタントアプリには複数のタブがあり、それぞれ帳票画像を表示するエリアを持っていた。

  • 読取一覧タブ: ナビゲーションボタンと位置表示あり

読取一覧タブのナビゲーション

  • クレカ明細タブ: ナビゲーションがない状態

クレカ明細タブ(ナビゲーションなし)

各タブが独自にナビゲーションUIを実装していたため、統一感がなく、同じコードが重複していた。

実装内容

1. 共通コンポーネントの作成

ImageNavigationPanel.vue を新規作成した。

<script setup lang="ts">
interface Props {
  title?: string
  currentIndex: number
  totalCount: number
}

const props = withDefaults(defineProps<Props>(), {
  title: '帳票画像',
})

const emit = defineEmits<{
  prev: []
  next: []
}>()

const positionDisplay = computed(() => {
  if (props.totalCount === 0) return null
  if (props.currentIndex < 0) return `- / ${props.totalCount}`
  return `${props.currentIndex + 1} / ${props.totalCount}`
})
</script>

<template>
  <div class="image-navigation-panel">
    <div class="panel-title">
      {{ title }}
      <div class="nav-controls">
        <button class="nav-btn" @click="emit('prev')">
          前へ(←キー)
        </button>
        <button class="nav-btn" @click="emit('next')">
          次へ(→キー)
        </button>
        <span v-if="positionDisplay" class="position-display">
          {{ positionDisplay }}
        </span>
      </div>
    </div>
    <div class="image-container">
      <slot />
    </div>
  </div>
</template>

ポイント:

  • Nuxt 3の自動インポート: computed 等のVue APIはNuxtが自動インポートするため、明示的なimportは不要
  • Props: currentIndex(0始まり、未選択時は -1)と totalCount(全体件数)を受け取る
  • Events: prev / next イベントを発火(ボタンの有効/無効制御は親コンポーネントで行う)
  • Slot: 画像コンテンツは親コンポーネントから注入
  • 位置表示: 未選択時(currentIndex < 0)は - / N と表示
  • キーボード操作: ボタンに「←キー」「→キー」と表示しているが、実際のキーボードイベント処理は親コンポーネント側で実装する設計

2. 各タブへの適用

MillerColumnsView(読取一覧)

<ImageNavigationPanel
  :current-index="globalIndex"
  :total-count="allItems.length"
  @prev="goPrev"
  @next="goNext"
>
  <img v-if="selectedImageUrl" :src="selectedImageUrl" />
  <div v-else class="no-data">レシートを選択</div>
</ImageNavigationPanel>

CreditCardMatchingView(クレカ明細)

<ImageNavigationPanel
  :current-index="selectedIndex"
  :total-count="filteredTransactions.length"
  @prev="goPrev"
  @next="goNext"
>
  <div class="preview-content-inner">
    <!-- 画像表示ロジック -->
  </div>
</ImageNavigationPanel>

SquareMatchingView(Square明細)

Square明細タブ

ReceiptImage(科目別一覧で使用)

科目別一覧タブ

ReceiptImage.vue は内部で ImageNavigationPanel を使うように変更した。外部インターフェース(props, events)を維持したまま、内部実装だけを共通コンポーネントに委譲する形にした。

<template>
  <div class="image-panel-wrapper">
    <ImageNavigationPanel
      :current-index="currentIndex ?? -1"
      :total-count="totalCount ?? 0"
      @prev="emit('prev')"
      @next="emit('next')"
    >
      <img v-if="imageUrl" :src="imageUrl" />
      <div v-else class="no-data">左のリストから項目を選択</div>
    </ImageNavigationPanel>
  </div>
</template>

統一後の状態

タブコンポーネント使用方法
読取一覧ImageNavigationPanel直接使用
科目別一覧ReceiptImage内部で使用
Square明細ImageNavigationPanel直接使用
クレカ明細ImageNavigationPanel直接使用

すべてのタブで「帳票画像」「前へ(←キー)」「次へ(→キー)」「N / M」の統一されたUIになった。

コンポーネントAPI

種類名前説明
Proptitlestringタイトル(デフォルト: '帳票画像')
PropcurrentIndexnumber現在位置(0始まり、未選択時は -1)
ProptotalCountnumber全体件数
Eventprev-「前へ」ボタンクリック時
Eventnext-「次へ」ボタンクリック時
Slotdefault-画像などのコンテンツ

注意点

  • ボタンのdisabled制御: コンポーネント内では行わない。親側で currentIndextotalCount を見てイベントをガードする
  • 境界値: currentIndex >= totalCount の場合は親側でクランプする想定
  • キーボードイベント: ボタン表記に「←キー」「→キー」とあるが、実際のキーリスナーは各タブのコンポーネントで window.addEventListener('keydown', ...) を登録する

まとめ

  • 重複していたナビゲーションUIを1つの共通コンポーネントに集約
  • Slotパターンを使い、画像以外のコンテンツ(複数画像表示など)にも対応
  • 既存コンポーネント(ReceiptImage)は外部インターフェースを維持しつつ内部実装を委譲
  • 今後のUI変更は ImageNavigationPanel.vue だけで対応できる