税務アシスタント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)対応の強化