家計簿アプリを作る #65:テーブルのカラムソート

家計簿アプリ作成シリーズの第65回です。収支一覧のテーブルで、「日付」や「金額」の列見出しをクリックすると、その列で昇順・降順に並び替えられるようにします。

ソート状態もURLのクエリパラメータで持つ

これまでの月・検索・カテゴリの絞り込みと同じパターンで、ソートも sort/order というクエリパラメータで持たせます。

type SortKey = 'date' | 'type' | 'category' | 'amount';

const allowedSortKeys: SortKey[] = ['date', 'type', 'category', 'amount'];
const rawSortKey = url.searchParams.get('sort') ?? 'date';
const sortKey: SortKey = allowedSortKeys.includes(rawSortKey as SortKey)
  ? (rawSortKey as SortKey)
  : 'date';

monthTransactions.sort((a, b) => {
  const diff = a[sortKey] > b[sortKey] ? 1 : -1;
  return sortOrder === 'asc' ? diff : -diff;
});

クエリパラメータから受け取った文字列をそのままオブジェクトのキーとして使おうとすると、TypeScriptの型エラーになりました。

型 'string' の式を使用して型 '{...}' にインデックスを付けることはできない

SortKey というリテラル型のユニオンを定義し、許可リストに含まれる値だけを通す形にすることで解決しました。任意の文字列がそのままインデックスアクセスに使われるのを防ぐ、型安全のための工夫です。

ソートアイコンを上下に重ねて表示する

CaretUpSolid/CaretDownSolid の2つのアイコンを縦に重ねて配置し、ソート状態に応じて色だけを切り替える見た目にしました。

<div class="flex flex-col leading-none -space-y-1">
  <CaretUpSolid class="h-3 w-3 {sortIconColor('date', 'asc')}" />
  <CaretDownSolid class="h-3 w-3 {sortIconColor('date', 'desc')}" />
</div>
function sortIconColor(column: string, direction: "asc" | "desc") {
  const isActive = data.sortKey === column && data.sortOrder === direction;
  return isActive ? "text-primary-600" : "text-gray-400";
}

-space-y-1 で2つのアイコンの間隔を詰め、1つのソートアイコンのように見せています。

ハマりポイント:ソートで列幅がガタつく

動作確認していると、列見出しをクリックしてソートするたびに、テーブル全体の横幅が左右にガタつく現象がありました。原因は、HTMLの <table> のデフォルト挙動(table-layout: auto)で、各列の幅が「実際に表示されている中身の最大幅」によって自動計算されるためでした。ソートで並び順が変わると、各列に表示される文字列の長さが変わり、列幅が再計算されて見た目が動いてしまいます。

最初は table-layout: fixed にして、各 TableHeadCell に明示的な幅を指定する方法を試しました。

<!-- ❌ 一時的にガタつきは直るが、別の問題(後述)を引き起こした -->
<Table shadow hoverable={true} class="table-fixed">
  <TableHead>
    <TableHeadCell class="w-28">日付 ...</TableHeadCell>
    <TableHeadCell class="w-24">種類 ...</TableHeadCell>
    <!-- ... -->
  </TableHead>

これでガタつきは直りましたが、代わりに今度はテーブル全体に不要な横スクロールバーが表示されるようになってしまいました。

横スクロールバーの原因究明

見た目上、テーブルの中身がはみ出しているようには見えないのに、外側の <div class="overflow-auto"> が横スクロールを検知して表示してしまう状態でした。まず疑ったのは、ヘッダセル内の flex items-center gap-1(見出し文字とソートアイコンを横並びにしている部分)です。Flexboxはデフォルトで min-width: auto となり、中身の最小幅を確保しようとして親のセル幅を無視してはみ出すことがある、というよくある挙動です。min-w-0 を追加して対策しましたが、これでも解消しませんでした。

最終的に、table-fixed 自体を外し、各列の w-XX 指定だけ残す(table-layout: auto に戻す)ことで、ガタつきも横スクロールも両方解消しました。

<!-- ✅ table-fixedは使わず、w-XXの指定だけ残す -->
<Table shadow hoverable={true}>
  <TableHead>
    <TableHeadCell class="w-28">日付 ...</TableHeadCell>
    <TableHeadCell class="w-24">種類 ...</TableHeadCell>
    <!-- ... -->
  </TableHead>

min-w-XX(最小幅だけ指定する方法)も試しましたが、これだとソート時のガタつきが再発してしまいました。結局、w-XX による「目安の幅」の指定だけに留め、レイアウトアルゴリズム自体は table-layout: auto のままにするのが、ガタつきと横スクロールの両方を避けられる着地点でした。table-fixed は列幅を厳密に固定できる分、境界線やFlexboxの最小幅などの微妙な誤差でも許容せずにコンテナからはみ出してしまう、というトレードオフがあったようです。

ハマりポイント:ページ送り後のソートで表示ページがズレる

ソートを実行すると1ページ目に戻す仕様にしていましたが、PaginationNav の表示上のページ番号(ローカルの currentPage という$state)だけがリセットされず、2ページ目のままハイライトされる不具合がありました。

これは、これまでに何度か遭遇した「サーバーから受け取った値をローカルの$stateとして二重管理してしまう」パターンのバグでした。ページ番号もサーバー側で計算済みの値をそのまま data として返し、ローカル $state を廃止することで解決しました。

// +page.server.ts の return に追加
return {
  // ...
  page: page,
};
<!-- ❌ ローカルstateを廃止 -->
<!-- let currentPage = $state(1); -->

<!-- ✅ data.pageを直接使う -->
<PaginationNav
  currentPage={data.page}
  totalPages={data.totalPages}
  onPageChange={handlePageChange}
/>

全ての操作でクエリパラメータを揃える

月変更・検索・カテゴリ変更・ページ送り・ソートの5つの操作それぞれで、URLに含めるクエリパラメータ(month, search, category, sort, order)を必ず全て揃えるようにしました。1箇所でも漏れると、その操作をした瞬間に他の絞り込み条件がリセットされてしまいます。

function handleMonthChange(event: Event) {
  const selectedMonth = (event.target as HTMLSelectElement).value;
  goto(
    `/?month=${selectedMonth}&page=1&search=${data.searchQuery}&category=${data.selectedCategory}&sort=${data.sortKey}&order=${data.sortOrder}`,
  );
}

まとめ

ポイント 内容
ソート対象列の型安全性 許可リストとリテラル型のユニオンで、任意の文字列によるインデックスアクセスを防ぐ
ソートアイコンの見た目 上下2つのアイコンを重ね、色だけをソート状態に応じて切り替える
列幅のガタつき w-XXによる目安の幅指定のみで抑える。table-fixedは厳密すぎて別の問題(横スクロール)を招いた
状態の二重管理 サーバーから受け取った値はローカル$stateを持たずdataを直接使う
複数条件の組み合わせ 全ての操作で同じパラメータ構成(月・検索・カテゴリ・ソート)を維持する
← トップページに戻る