ハコヅミを作る #20:ギアとキャンプの一覧を、カード表示に切り替えられるようにする
ギアにも(#12)、キャンプにも(#15)画像を付けられるようにしましたが、一覧は文字のリストのままでした。画像は、編集画面やキャンプの画面を開かないと見られません。
今回は、一覧をカード表示に切り替えられるようにします。ギアの一覧(#84)と、キャンプの一覧(#85)です。Notion の構想にあった「ギアのビジュアル表示」にあたります。
できあがった画面

ギアの一覧のカード表示。画像のないギアは、頭文字のカードになる
画面は、この記事の時点のコードを手元で動かして撮ったものです。ギアは#7で Notion から取り込んだ実際のもので、キャンプ場は記事用の架空のものです。画像は、記事用に色だけの絵を付けています。

キャンプの一覧のカード表示。進み具合は、数と細い棒で出す
表示の形は、URL で持つ
「リスト」と「カード」のどちらで見ているかは、URL の ?view=card で持ちます。なければリストです。
- リロードしても、編集画面から戻っても、同じ表示が続く
- JavaScript の状態や保存する場所を増やさずに済む(切り替えは、ただのリンク)
- 決められた値以外(
?view=gridなど)は、リストとして扱う。URL を手で書き換えられても、エラーにしない
ギアの一覧には、#14で作った状態のタブ(?status=wanted)もあります。カード表示のままタブを切り替えたら、カード表示のまま移ってほしいので、リンクの URL を作る処理を1つにまとめました。
// 既定の値(所有・リスト)は URL に付けない。base は resolve('/gears') した値
export function gearListHref(base: string, status: GearStatus, view: ListView): string {
const params = new URLSearchParams();
if (status !== DEFAULT_GEAR_STATUS) params.set('status', status);
if (view !== 'list') params.set('view', view);
const query = params.toString();
return query ? `${base}?${query}` : base;
}
既定の値を URL に付けないので、ふつうに開いたときの URL は /gears のままです。これまでの E2E も、変えずに通りました。
カードの中身
| ギア | キャンプ | |
|---|---|---|
| 画像 | 正方形に切り抜く | 横長(16:9)に切り抜く |
| 画像がないとき | 淡い背景に、製品名の頭文字 | 淡い背景に、キャンプ場の名前(なければプラン名)の頭文字 |
| 文字 | 製品名、おすすめ度の★、メーカー・コンテナのバッジ | キャンプ場の名前、予定期間、実績期間、プラン名、進み具合 |
| 押すと | 編集画面へ | キャンプの画面へ |
画像がないときの頭文字
今のデータは、ほとんどのギアに画像がありません。画像のないカードを空白にすると、カード表示にした意味がなくなるので、淡い緑(primary-soft)の背景に、名前の最初の1文字を大きく出しました。
1文字を取り出すところには、小さな注意があります。JavaScript の文字列は、絵文字などを2つの符号で表すので、name[0] と書くと、文字の半分だけが取れてしまいます。
// 最初の1文字。絵文字などが途中で切れないように数える
export function initialOf(text: string): string {
return Array.from(text.trim())[0] ?? '';
}
画像は、画面に入るまで読み込まない
カード表示では、1画面に何十枚もの画像が並びます。全部を一度に読み込むと重いので、loading="lazy" を付けて、画面に入るまで読み込まないようにしました。この1行が、あとでテストを落とすことになります。
2回目に使うときに、部品にした
切り替えのボタンは、まずギアの一覧に直接書きました(#84)。キャンプの一覧(#85)で2回目に使うときに、部品(ViewToggle)にして、ギアの側も置き換えています。
?view= を読む処理と、頭文字の処理も、ギア専用のファイルから共通のファイル(src/lib/list-view.ts)に移しました。
最初から共通の部品として作ることもできましたが、ハコヅミでは「抽象化は必要になってから」と決めています。1つ目のときは、キャンプの一覧でどう使うかが、まだ見えていません。2つ目を作るときなら、何が同じで、何が違うか(ギアには状態のタブがあり、キャンプにはない)が分かったうえで、部品の形を決められます。
部品が受け取るのは、今の表示と、それぞれの表示の URL だけにしました。
<ViewToggle
view={data.view}
hrefs={{ list: campListHref(base, 'list'), card: campListHref(base, 'card') }}
/>
URL の作り方(状態のタブを含めるかどうか)は、使う側が決めます。部品は、渡された URL にリンクを張るだけです。
スマホの幅で、文字が縦に折れた
テストが通ったあと、スマホの幅(375px)で画面を撮ってみると、ギアの一覧の上のほうが崩れていました。状態のタブ3つと切り替えを1行に並べたので、幅が足りず、「欲しい(3)」や「リスト」の文字が1文字ずつ縦に折れています。
直し方は、「幅が足りないときは、切り替えをタブの上の行に回す」です。タブは下のカードとつながって見える作り(#14)なので、タブのほうを下の行に残す必要があります。
<!-- 幅が足りないとき(スマホ)は、表示の形の切り替えをタブの上の行に回す -->
<div class="mt-4 flex flex-wrap-reverse items-end justify-between gap-x-2">
<nav aria-label="ギアの状態" class="flex gap-1">...</nav>
<ViewToggle ... class="mb-1 ml-auto" />
</div>
flex-wrap-reverse は、折り返した要素を上の行に置きます。ふつうの flex-wrap だと、後ろに書いた切り替えが下の行に回り、タブとカードの間に割り込んでしまいます。

スマホの幅では2列。画像を付けたギアは、画像のカードになる
E2E は、文字が折れていても通ります。テストが確かめているのは「リンクがあって、押せる」ことで、見た目ではないからです。画面を撮って目で見る手順は、今回も役に立ちました。
E2E が時間切れになった:loading="lazy" の落とし穴
キャンプ側の実装を終えて、手元で E2E の全件を流すと、1件だけ落ちました。落ちたのは、今回の変更ではなく、前の PR で足して、すでにマージ済みのギアのカード表示のテストです。CI では通っていました。
時間切れになっていたのは、この部分です。
const image = imageCard.locator('img');
await expect(image).toBeVisible();
const loaded = await image.evaluate(async (el) => {
if (!(el instanceof HTMLImageElement)) return false;
await el.decode(); // ← ここで止まる
return el.naturalWidth > 0;
});
画像の読み込みが終わるのを decode() で待ってから、ちゃんと絵が出ているかを確かめています(#12で、読み込みを待たずに幅を読んで CI で落ちたときの対策です)。
ところが、カードの画像は loading="lazy" です。画面の外にある間は、読み込みが始まりません。始まらない読み込みを待ち続けて、時間切れになっていました。
Playwright の toBeVisible() は、「表示される状態にある」ことを確かめるもので、「今、画面の中に見えている」ことまでは見ません。画面の外にあっても通ります。
なぜ手元でだけ起きたか
ハコヅミの E2E は、ローカルの D1 を消さずに使い回します(データの名前を重ならないようにして、共存させる作りです)。手元の D1 には、これまでのテストのギアが 6,000 件以上たまっていました。テストで登録したギアは、その長い一覧の下のほうに出るので、画面の外になります。
CI は、毎回空の D1 から始まります。ギアが少ないので、対象のカードが最初から画面の中にあり、たまたま通っていました。ただ、同じ実行の中でもテストが進むほどギアは増えるので、CI でも落ちておかしくないテストでした。
直し方
読み込みを待つ前に、画像を画面の中に入れます。
await expect(image).toBeVisible();
// 画面に入るまで読み込まない(loading="lazy")ので、画面に入れてから読み込みを待つ
await image.scrollIntoViewIfNeeded();
const loaded = await image.evaluate(async (el) => { ... });
キャンプ側のテストも同じ形で書いていたので、いっしょに直しました。CLAUDE.md の E2E の注意にも、1行足しています。
#6から続いている「待つべきものを待っていない」の仲間ですが、今回は逆で、待っても来ないものを待っていた例でした。アプリを速くするための1行(loading="lazy")が、テストの前提を変えていたわけです。
なお、E2E は 147 件になり、手元で1回に流すと10分を超えるようになりました。手元でも、2つに分けて流しています。
おわりに
- 表示の形は URL で持つ。既定の値は URL に付けない
- 画像のないカードは、頭文字で埋める。1文字を取るときは
Array.from()で - 部品にするのは、2回目に使うとき。何が同じで何が違うかが、見えてから
flex-wrap-reverseで、折り返した要素を上の行に回すloading="lazy"の画像は、画面に入れてから読み込みを待つ
これで、Notion の「今後の機能強化」のステップ1(自分用の機能を充実させる)は、すべてチェックが付きました。ここまでの5つの Issue は、まとめて本番にリリースしています。
次は、キャンプ場の情報を、過去のキャンプから引用できるようにする予定です。キャンプ場を別のテーブル(マスタ)で管理する案をやめた経緯も、そのときに書きます。