概要
tax-assistantの仕訳ルール管理画面において、キーボードナビゲーションの改善と、それに伴うデザインシステムへの原則追加を行った。
主な作業内容:
- パターンカラムの勘定科目順ソート
allFilteredRulesのソート順を表示順と一致させる修正- ループナビゲーション原則の導入
- デザインシステム(
/design-system/ui/keyboard)への原則追加 - 履歴ナビゲーションテストの修正(
waitForURL使用)
1. パターンカラムの勘定科目順ソート
問題
仕訳ルールのルール一覧ビューで、パターンカラムの表示が「パターン名 + 勘定科目」の順になっていて、ソートもされていなかった。
修正内容
パターンリストを勘定科目で日本語の五十音順にソートするよう修正。
// 修正前
const patternList = computed(() => {
return rules.value.filter(r => r.account === selectedAccount.value)
})
// 修正後
const patternList = computed(() => {
return rules.value
.filter(r => r.account === selectedAccount.value)
.sort((a, b) => a.account.localeCompare(b.account, 'ja'))
})
2. allFilteredRules のソート順修正
問題
矢印キー(左右)で移動する allFilteredRules のソート順が、実際のUI表示順と一致していなかった。これにより、矢印キーで移動したときに意図しない順番でルールが選択されていた。
原因
- UI表示: 勘定科目の件数降順
allFilteredRules: アルファベット順
この不一致が問題だった。
修正内容
allFilteredRules のソート順をUI表示順と同じく「勘定科目件数の降順」に変更。
// 修正前
const allFilteredRules = computed(() => {
const filtered = rules.value.filter(r => r.docTypeId === selectedDocTypeId.value)
return filtered.sort((a, b) => a.account.localeCompare(b.account, 'ja'))
})
// 修正後
const allFilteredRules = computed(() => {
const filtered = rules.value.filter(r => r.docTypeId === selectedDocTypeId.value)
// 勘定科目ごとの件数を計算
const accountCounts = new Map<string, number>()
filtered.forEach(r => {
accountCounts.set(r.account, (accountCounts.get(r.account) || 0) + 1)
})
// 件数降順でソート
return filtered.sort((a, b) => {
const countDiff = (accountCounts.get(b.account) || 0) - (accountCounts.get(a.account) || 0)
if (countDiff !== 0) return countDiff
return a.account.localeCompare(b.account, 'ja')
})
})
テストコード追加
ソート順の整合性をテストで担保するため、コンポーネントテストを追加。
// frontend/app/components/__tests__/RuleListViewSorting.spec.ts
import { describe, it, expect } from 'vitest'
describe('RuleListView ソートロジック', () => {
it('allFilteredRulesのソート順がaccountListと一致する', () => {
// テストデータ
const rules = [
{ id: 1, account: '消耗品費', docTypeId: 5 },
{ id: 2, account: '接待交際費', docTypeId: 5 },
{ id: 3, account: '消耗品費', docTypeId: 5 },
// ...
]
// 件数計算
const accountCounts = new Map<string, number>()
rules.forEach(r => {
accountCounts.set(r.account, (accountCounts.get(r.account) || 0) + 1)
})
// allFilteredRulesと同じソート
const sortedRules = [...rules].sort((a, b) => {
const countDiff = (accountCounts.get(b.account) || 0) - (accountCounts.get(a.account) || 0)
if (countDiff !== 0) return countDiff
return a.account.localeCompare(b.account, 'ja')
})
// accountListと同じソート
const accounts = [...new Set(rules.map(r => r.account))]
const sortedAccounts = accounts.sort((a, b) => {
const countDiff = (accountCounts.get(b) || 0) - (accountCounts.get(a) || 0)
if (countDiff !== 0) return countDiff
return a.localeCompare(b, 'ja')
})
// 整合性チェック
sortedRules.forEach((rule, index) => {
const expectedAccountIndex = sortedAccounts.indexOf(rule.account)
expect(expectedAccountIndex).toBeGreaterThanOrEqual(0)
})
})
})
3. ループナビゲーション原則の導入
問題
従来の実装では、インデックス0番目で左矢印を押しても移動しなかった。同様に、最後のインデックスで右矢印を押しても移動しなかった。
ユーザーの要望
「67番目に行ったら1番に戻って欲しい。1番から左に行ったら67番に行って欲しい」
修正内容
矢印キーナビゲーションをループするように修正。
// 修正前
const goToPrevRule = () => {
if (currentRuleIndex.value > 0) {
selectRuleByIndex(currentRuleIndex.value - 1)
}
}
const goToNextRule = () => {
if (currentRuleIndex.value < allFilteredRules.value.length - 1) {
selectRuleByIndex(currentRuleIndex.value + 1)
}
}
// 修正後
const goToPrevRule = () => {
const total = allFilteredRules.value.length
if (total === 0) return
const newIndex = currentRuleIndex.value <= 0
? total - 1 // 最初から最後へループ
: currentRuleIndex.value - 1
selectRuleByIndex(newIndex)
}
const goToNextRule = () => {
const total = allFilteredRules.value.length
if (total === 0) return
const newIndex = currentRuleIndex.value >= total - 1
? 0 // 最後から最初へループ
: currentRuleIndex.value + 1
selectRuleByIndex(newIndex)
}
4. デザインシステムへの原則追加
追加内容
/design-system/ui/keyboard にループナビゲーション原則を追加。
## ループナビゲーション原則
### 基本ルール
リスト内のキーボードナビゲーションは、デフォルトでループする:
- **最後のアイテム → 次へ**: 最初のアイテムに移動
- **最初のアイテム → 前へ**: 最後のアイテムに移動
### 理由
1. **効率性**: 67件あるリストで、最後から最初に戻りたい時に66回キーを押す必要がない
2. **一貫性**: どの位置からでも全てのアイテムにアクセス可能
3. **ユーザー体験**: 行き止まりがないため、操作の流れが途切れない
### 例外
以下のケースでは、ループしない方が適切:
- **順序に意味があるコンテンツ**: 記事の前後ページなど、最初/最後の概念が重要な場合
- **無限スクロール**: 新しいコンテンツが追加される可能性がある場合
### 実装例
```typescript
const goToNext = () => {
const total = items.value.length
if (total === 0) return
currentIndex.value = currentIndex.value >= total - 1
? 0 // ループ
: currentIndex.value + 1
}
const goToPrev = () => {
const total = items.value.length
if (total === 0) return
currentIndex.value = currentIndex.value <= 0
? total - 1 // ループ
: currentIndex.value - 1
}
### CSSの追加
ループナビゲーション対応のボタンスタイルも追加。
```css
/* ループナビゲーションではボタンを常にアクティブに見せる */
.nav-button {
opacity: 1;
cursor: pointer;
}
/* 非ループナビゲーションでは端でdisabled表示 */
.nav-button.no-loop:disabled {
opacity: 0.5;
cursor: not-allowed;
}
5. 履歴ナビゲーションテストの修正
問題
履歴ナビゲーションのE2Eテストで、waitForTimeout を使っていたが不安定だった。
// 修正前(不安定)
await page.goBack()
await page.waitForTimeout(500)
expect(page.url()).toContain('tab=receipt')
原因
waitForTimeout は固定時間待機するため:
- 遅い環境ではタイムアウトが足りず失敗
- 速い環境では無駄に待機
修正内容
waitForURL を使用して、URLが実際に変わるのを待つように修正。
// 修正後(安定)
await page.goBack()
await page.waitForURL(/tab=receipt/)
expect(page.url()).toContain('tab=receipt')
全体の修正
// frontend/e2e/history-navigation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('履歴ナビゲーション', () => {
test('タブ切り替え時にURLが履歴に記録される', async ({ page }) => {
await page.goto('/')
// 重複チェックタブをクリック
await page.click('[data-testid="tab-duplicate"]')
await page.waitForURL(/tab=duplicate/)
// 戻る
await page.goBack()
await page.waitForURL(/tab=receipt/)
// UIも更新されていることを確認
const activeTab = page.locator('.tab-item.active')
await expect(activeTab).toContainText('読取一覧')
})
test('ブラウザの進む/戻るでタブが正しく切り替わる', async ({ page }) => {
await page.goto('/')
// 複数のタブを順番にクリック
await page.click('[data-testid="tab-duplicate"]')
await page.waitForURL(/tab=duplicate/)
await page.click('[data-testid="tab-matrix"]')
await page.waitForURL(/tab=matrix/)
// 2回戻る
await page.goBack()
await page.waitForURL(/tab=duplicate/)
await page.goBack()
await page.waitForURL(/tab=receipt/)
// 進む
await page.goForward()
await page.waitForURL(/tab=duplicate/)
})
})
テストの直列実行設定
並列実行時の干渉を防ぐため、履歴ナビゲーションテストは直列実行するように設定。
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'history-navigation',
testMatch: /history-navigation\.spec\.ts/,
fullyParallel: false, // 直列実行
},
// ...
],
})
まとめ
今回の改善により:
- 矢印キーでの移動順がUI表示順と一致 - ユーザーが見ている順番と操作結果が一致
- ループナビゲーションで効率的な操作 - 最後から最初へ、最初から最後へスムーズに移動
- デザインシステムに原則を文書化 - 今後の開発でも一貫した実装が可能
- E2Eテストの安定化 -
waitForURLによる確実な待機
これらの改善は、キーボードを多用するパワーユーザーにとって特に効果的。大量のデータを扱う税務処理では、マウス操作を減らしてキーボードで素早く操作できることが重要になる。