• #tax-assistant
  • #keyboard-navigation
  • #design-system
  • #vue
  • #playwright
  • #e2e-test
開発tax-assistantメモ

概要

tax-assistantの仕訳ルール管理画面において、キーボードナビゲーションの改善と、それに伴うデザインシステムへの原則追加を行った。

主な作業内容:

  1. パターンカラムの勘定科目順ソート
  2. allFilteredRules のソート順を表示順と一致させる修正
  3. ループナビゲーション原則の導入
  4. デザインシステム(/design-system/ui/keyboard)への原則追加
  5. 履歴ナビゲーションテストの修正(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,  // 直列実行
    },
    // ...
  ],
})

まとめ

今回の改善により:

  1. 矢印キーでの移動順がUI表示順と一致 - ユーザーが見ている順番と操作結果が一致
  2. ループナビゲーションで効率的な操作 - 最後から最初へ、最初から最後へスムーズに移動
  3. デザインシステムに原則を文書化 - 今後の開発でも一貫した実装が可能
  4. E2Eテストの安定化 - waitForURL による確実な待機

これらの改善は、キーボードを多用するパワーユーザーにとって特に効果的。大量のデータを扱う税務処理では、マウス操作を減らしてキーボードで素早く操作できることが重要になる。