• #Vue.js
  • #デザインシステム
  • #Miller Columns
  • #Pinia
  • #コンポーネント設計
  • #UI/UX
開発tax-assistantメモ

税務アシスタントUIデザインシステム構築

2026年1月26日、税務アシスタントアプリのUIデザインシステムを大規模にリファクタリングした。巨大な単一ファイルを複数に分割し、ナビゲーションやソート機能を共通コンポーネントとして整備した。

背景と課題

デザインシステムページ(design-system.vue)が27,000トークンを超える巨大ファイルに成長しており、以下の問題があった。

  • 編集のたびにファイル全体の読み込みが必要
  • セクション間の依存関係が不明確
  • コンポーネントの再利用が困難
  • テストの記述が難しい

実施内容

1. デザインシステムページの物理分割

単一の巨大ファイルを以下の構造に分割した。

pages/design-system/
├── index.vue           # トップページ(カテゴリ一覧)
├── colors/
│   ├── action.vue      # アクションカラー(青)
│   └── status.vue      # 状態カラー
├── ui/
│   ├── layout.vue      # Miller Columnsレイアウト
│   ├── keyboard.vue    # キーボードナビゲーション
│   ├── progress.vue    # プログレスバー
│   ├── file.vue        # ファイルリスト
│   └── selection.vue   # 選択UI(ラジオ/チェックボックス)
├── tables.vue          # SortableTable
├── badges.vue          # ステータスバッジ
└── icons.vue           # アイコン一覧

分割後、各ファイルは1,000〜3,000トークン程度となり、編集や理解が容易になった。

2. Miller Columnsレイアウトの共通化

デザインシステム全体で統一的なMiller Columnsレイアウトを採用。共通レイアウトファイルを作成し、全ページで利用できるようにした。

<!-- layouts/design-system.vue -->
<template>
  <div class="design-system-layout">
    <aside class="miller-columns">
      <div class="column column-1">
        <ul>
          <li v-for="category in categories" :key="category.id"
              :class="{ active: isActive(category) }"
              @click="selectCategory(category)">
            {{ category.label }}
          </li>
        </ul>
      </div>
      <div class="column column-2">
        <ul>
          <li v-for="item in currentItems" :key="item.id"
              :class="{ active: isActiveItem(item) }"
              @click="selectItem(item)">
            {{ item.label }}
          </li>
        </ul>
      </div>
    </aside>
    <main class="content">
      <slot />
    </main>
  </div>
</template>

<style scoped>
.design-system-layout {
  display: flex;
  height: 100vh;
}

.miller-columns {
  display: flex;
  width: 320px;
  border-right: 1px solid #e5e7eb;
}

.column {
  width: 160px;
  border-right: 1px solid #e5e7eb;
  overflow-y: auto;
}

.column li {
  padding: 8px 12px;
  cursor: pointer;
  font-size: 13px;
}

.column li:hover {
  background: #f3f4f6;
}

.column li.active {
  background: #3b82f6;
  color: white;
}

.content {
  flex: 1;
  padding: 24px;
  overflow-y: auto;
}
</style>

3. Piniaストアによるナビゲーション状態管理

ナビゲーション状態をPiniaストアで一元管理。選択中のカテゴリや項目、履歴などを保持する。

// stores/designSystemNav.ts
import { defineStore } from 'pinia'

interface NavItem {
  id: string
  label: string
  path: string
  children?: NavItem[]
}

export const useDesignSystemNavStore = defineStore('designSystemNav', {
  state: () => ({
    categories: [
      {
        id: 'colors',
        label: 'カラーパレット',
        path: '/design-system/colors',
        children: [
          { id: 'colors-action', label: 'アクション(青)', path: '/design-system/colors/action' },
          { id: 'colors-status', label: '状態', path: '/design-system/colors/status' },
        ]
      },
      {
        id: 'ui',
        label: 'UIパターン',
        path: '/design-system/ui',
        children: [
          { id: 'ui-layout', label: 'レイアウト', path: '/design-system/ui/layout' },
          { id: 'ui-keyboard', label: 'キーボードナビ', path: '/design-system/ui/keyboard' },
          // ...
        ]
      },
      // ...
    ] as NavItem[],
    selectedCategoryId: null as string | null,
    selectedItemId: null as string | null,
  }),

  getters: {
    flatItems(): NavItem[] {
      return this.categories.flatMap(cat => cat.children || [cat])
    },
    currentIndex(): number {
      return this.flatItems.findIndex(item => item.id === this.selectedItemId)
    },
    totalItems(): number {
      return this.flatItems.length
    },
  },

  actions: {
    selectItem(itemId: string) {
      this.selectedItemId = itemId
      const item = this.flatItems.find(i => i.id === itemId)
      if (item) {
        const category = this.categories.find(c =>
          c.children?.some(child => child.id === itemId)
        )
        if (category) {
          this.selectedCategoryId = category.id
        }
      }
    },
    goNext() {
      const idx = this.currentIndex
      if (idx < this.flatItems.length - 1) {
        this.selectItem(this.flatItems[idx + 1].id)
        return this.flatItems[idx + 1].path
      }
      return null
    },
    goPrev() {
      const idx = this.currentIndex
      if (idx > 0) {
        this.selectItem(this.flatItems[idx - 1].id)
        return this.flatItems[idx - 1].path
      }
      return null
    },
    syncFromRoute(path: string) {
      const item = this.flatItems.find(i => path.startsWith(i.path))
      if (item) {
        this.selectItem(item.id)
      }
    }
  }
})

4. useDesignSystemNav composable

ストアとルーターを連携させるcomposableを作成。キーボードナビゲーションのロジックもここに集約。

// composables/useDesignSystemNav.ts
import { useDesignSystemNavStore } from '~/stores/designSystemNav'
import { useRouter, useRoute } from 'vue-router'
import { onMounted, onUnmounted, computed } from 'vue'

export function useDesignSystemNav() {
  const store = useDesignSystemNavStore()
  const router = useRouter()
  const route = useRoute()

  const currentIndex = computed(() => store.currentIndex + 1)
  const totalItems = computed(() => store.totalItems)
  const position = computed(() => `${currentIndex.value} / ${totalItems.value}`)

  function goNext() {
    const path = store.goNext()
    if (path) router.push(path)
  }

  function goPrev() {
    const path = store.goPrev()
    if (path) router.push(path)
  }

  function handleKeydown(e: KeyboardEvent) {
    if (e.key === 'ArrowRight') {
      e.preventDefault()
      goNext()
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault()
      goPrev()
    }
  }

  onMounted(() => {
    store.syncFromRoute(route.path)
    window.addEventListener('keydown', handleKeydown)
  })

  onUnmounted(() => {
    window.removeEventListener('keydown', handleKeydown)
  })

  return {
    currentIndex,
    totalItems,
    position,
    goNext,
    goPrev,
    categories: computed(() => store.categories),
    selectedCategoryId: computed(() => store.selectedCategoryId),
    selectedItemId: computed(() => store.selectedItemId),
    selectItem: store.selectItem,
  }
}

5. SortableTableのマルチカラムソート対応

従来の単一カラムソートから、Excel方式のマルチカラムソート(AND条件)に拡張した。後からクリックしたカラムが優先順位1になる。

// components/SortableTable.vue
<script setup lang="ts">
interface SortState {
  column: string
  direction: 'asc' | 'desc'
}

const sortStates = ref<SortState[]>([])

function toggleSort(column: string) {
  const existingIndex = sortStates.value.findIndex(s => s.column === column)

  if (existingIndex === -1) {
    // 新規追加:先頭に挿入(Excel方式 - 後からクリックが優先)
    sortStates.value.unshift({ column, direction: 'asc' })
  } else {
    const current = sortStates.value[existingIndex]
    if (current.direction === 'asc') {
      current.direction = 'desc'
      // 優先順位を先頭に移動
      sortStates.value.splice(existingIndex, 1)
      sortStates.value.unshift(current)
    } else {
      // 降順→解除
      sortStates.value.splice(existingIndex, 1)
    }
  }
}

function getSortPriority(column: string): number | null {
  const idx = sortStates.value.findIndex(s => s.column === column)
  return idx >= 0 ? idx + 1 : null
}

function getSortDirection(column: string): 'asc' | 'desc' | null {
  const state = sortStates.value.find(s => s.column === column)
  return state?.direction || null
}

const sortedData = computed(() => {
  if (sortStates.value.length === 0) return props.data

  return [...props.data].sort((a, b) => {
    for (const { column, direction } of sortStates.value) {
      const aVal = a[column]
      const bVal = b[column]
      const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
      if (cmp !== 0) {
        return direction === 'asc' ? cmp : -cmp
      }
    }
    return 0
  })
})
</script>

6. SortIndicatorとSortableHeaderコンポーネント

ソートUIを共通化するため、2つのコンポーネントを作成した。

<!-- components/SortIndicator.vue -->
<template>
  <span class="sort-indicator" :class="{ active }">
    <template v-if="!active"></template>
    <template v-else-if="direction === 'asc'"></template>
    <template v-else></template>
    <span v-if="priority" class="priority-badge">{{ priority }}</span>
  </span>
</template>

<script setup lang="ts">
defineProps<{
  active: boolean
  direction?: 'asc' | 'desc' | null
  priority?: number | null
}>()
</script>

<style scoped>
.sort-indicator {
  margin-left: 4px;
  color: #9ca3af;
  font-size: 10px;
}

.sort-indicator.active {
  color: #3b82f6;
}

.priority-badge {
  font-size: 9px;
  vertical-align: super;
  margin-left: 2px;
}
</style>
<!-- components/SortableHeader.vue -->
<template>
  <component :is="tag" class="sortable-header" @click="$emit('sort')">
    <slot />
    <SortIndicator
      :active="active"
      :direction="direction"
      :priority="priority"
    />
  </component>
</template>

<script setup lang="ts">
import SortIndicator from './SortIndicator.vue'

defineProps<{
  tag?: string
  active: boolean
  direction?: 'asc' | 'desc' | null
  priority?: number | null
}>()

withDefaults(defineProps(), {
  tag: 'th'
})

defineEmits<{
  sort: []
}>()
</script>

<style scoped>
.sortable-header {
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}

.sortable-header:hover {
  background: #f3f4f6;
}
</style>

7. 全4箇所のソートUI統一

以下の4コンポーネントのソートUIを共通化した。

コンポーネント用途変更内容
MillerColumnsView科目別一覧の内訳SortableHeader適用
MatrixView月次推移表のドリルダウンSortableHeader適用
ReceiptList読取一覧のサイドバーSortableHeader適用
ResultTable結果タブのテーブルSortableHeader適用

Before:

<th class="sortable" @click="toggleSort('date')">
  日付
  <span class="sort-indicator" :class="{ active: sortColumn === 'date' }">
    {{ sortColumn === 'date' ? (sortDirection === 'asc' ? '▲' : '▼') : '↕' }}
  </span>
</th>

After:

<SortableHeader
  :active="sortColumn === 'date'"
  :direction="sortColumn === 'date' ? sortDirection : null"
  @sort="toggleSort('date')"
>
  日付
</SortableHeader>

8. アイコンコンポーネントの作成

デザインシステムにアイコンページを追加し、以下のアイコンを共通コンポーネント化した。

<!-- components/icons/TrashIcon.vue -->
<template>
  <svg xmlns="http://www.w3.org/2000/svg"
       :width="size" :height="size"
       viewBox="0 0 24 24"
       fill="none"
       :stroke="color"
       stroke-width="2"
       stroke-linecap="round"
       stroke-linejoin="round">
    <polyline points="3 6 5 6 21 6"></polyline>
    <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
  </svg>
</template>

<script setup lang="ts">
withDefaults(defineProps<{
  size?: number
  color?: string
}>(), {
  size: 16,
  color: '#dc2626'  // 削除は赤色
})
</script>
<!-- components/icons/CheckIcon.vue -->
<template>
  <svg xmlns="http://www.w3.org/2000/svg"
       :width="size" :height="size"
       viewBox="0 0 24 24"
       fill="none"
       :stroke="color"
       stroke-width="2"
       stroke-linecap="round"
       stroke-linejoin="round">
    <polyline points="20 6 9 17 4 12"></polyline>
  </svg>
</template>

<script setup lang="ts">
withDefaults(defineProps<{
  size?: number
  color?: string
}>(), {
  size: 16,
  color: '#22c55e'  // 確定済みは緑色
})
</script>
<!-- components/icons/CircleIcon.vue -->
<template>
  <svg xmlns="http://www.w3.org/2000/svg"
       :width="size" :height="size"
       viewBox="0 0 24 24"
       :fill="fill ? color : 'none'"
       :stroke="color"
       stroke-width="2">
    <circle cx="12" cy="12" r="10"></circle>
  </svg>
</template>

<script setup lang="ts">
withDefaults(defineProps<{
  size?: number
  color?: string
  fill?: boolean
}>(), {
  size: 16,
  color: '#f97316',  // 未確定はオレンジ
  fill: true
})
</script>

アイコンの使い分け:

アイコン用途
TrashIcon赤 (#dc2626)データ削除
CheckIcon緑 (#22c55e)確定済み状態
CircleIconオレンジ (#f97316)未確定状態

9. Mermaid図のクライアントサイドレンダリング

状態遷移図をSVGとしてインラインで埋め込むのではなく、Mermaidのクライアントサイドレンダリングに対応した。

<template>
  <div class="mermaid-container">
    <pre class="mermaid">
stateDiagram-v2
    [*] --> Idle: 初期状態
    Idle --> Hovering: マウスオーバー
    Hovering --> Idle: マウスアウト
    Hovering --> Active: クリック
    Active --> Idle: 確定/キャンセル
    </pre>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'

onMounted(async () => {
  const mermaid = await import('mermaid')
  mermaid.default.initialize({ startOnLoad: true })
  mermaid.default.run()
})
</script>

10. キーボードナビゲーション(左右キー)の共通化

左右矢印キーによるナビゲーションをuseKeyboardNav composableとして共通化し、複数のコンポーネントで再利用できるようにした。

// composables/useKeyboardNav.ts
import { onMounted, onUnmounted, ref } from 'vue'

interface UseKeyboardNavOptions {
  onNext: () => void
  onPrev: () => void
  isEnabled?: () => boolean
}

export function useKeyboardNav(options: UseKeyboardNavOptions) {
  const { onNext, onPrev, isEnabled = () => true } = options

  function handleKeydown(e: KeyboardEvent) {
    if (!isEnabled()) return

    if (e.key === 'ArrowRight') {
      e.preventDefault()
      onNext()
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault()
      onPrev()
    }
  }

  onMounted(() => {
    window.addEventListener('keydown', handleKeydown)
  })

  onUnmounted(() => {
    window.removeEventListener('keydown', handleKeydown)
  })
}

使用例(NavigationBar.vueとの連携):

<template>
  <div class="navigation-bar">
    <button @click="goPrev" :disabled="currentIndex <= 1">← 前へ</button>
    <span class="position">{{ currentIndex }} / {{ total }}</span>
    <button @click="goNext" :disabled="currentIndex >= total">次へ →</button>
  </div>
</template>

<script setup lang="ts">
import { useKeyboardNav } from '~/composables/useKeyboardNav'

const props = defineProps<{
  currentIndex: number
  total: number
}>()

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

function goPrev() {
  emit('prev')
}

function goNext() {
  emit('next')
}

useKeyboardNav({
  onNext: goNext,
  onPrev: goPrev,
})
</script>

テスト

キーボードナビゲーションとソート機能のテストを作成した。

// tests/composables/useKeyboardNav.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useKeyboardNav } from '~/composables/useKeyboardNav'

describe('useKeyboardNav', () => {
  it('calls onNext when ArrowRight is pressed', () => {
    const onNext = vi.fn()
    const onPrev = vi.fn()

    useKeyboardNav({ onNext, onPrev })

    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))

    expect(onNext).toHaveBeenCalled()
    expect(onPrev).not.toHaveBeenCalled()
  })

  it('calls onPrev when ArrowLeft is pressed', () => {
    const onNext = vi.fn()
    const onPrev = vi.fn()

    useKeyboardNav({ onNext, onPrev })

    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))

    expect(onPrev).toHaveBeenCalled()
    expect(onNext).not.toHaveBeenCalled()
  })

  it('respects isEnabled option', () => {
    const onNext = vi.fn()
    const onPrev = vi.fn()

    useKeyboardNav({
      onNext,
      onPrev,
      isEnabled: () => false
    })

    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))

    expect(onNext).not.toHaveBeenCalled()
  })
})

成果

  • コード量削減: 重複コードの削除により、全体で約2,000行のコードを削減
  • 保守性向上: 各ファイルが1,000〜3,000トークンに収まり、編集が容易に
  • 一貫性確保: ソートUI、ナビゲーション、アイコンがアプリ全体で統一
  • テスト容易性: 共通コンポーネント単位でのテストが可能に

今後の展望

  • デザインシステムページへのStorybookの導入検討
  • コンポーネントカタログの自動生成
  • アクセシビリティ(a11y)対応の強化