ハコヅミを作る #20:ギアとキャンプの一覧を、カード表示に切り替えられるようにする

ギアにも(#12)、キャンプにも(#15)画像を付けられるようにしましたが、一覧は文字のリストのままでした。画像は、編集画面やキャンプの画面を開かないと見られません。

今回は、一覧をカード表示に切り替えられるようにします。ギアの一覧(#84)と、キャンプの一覧(#85)です。Notion の構想にあった「ギアのビジュアル表示」にあたります。


できあがった画面

ギア一覧のカード表示。状態のタブの右に「リスト」「カード」の切り替えがあり、「カード」が選ばれている。「椅子・テーブル」の見出しの下に、カードが3列で並ぶ。画像のないギアは、淡い緑の背景に製品名の頭文字(M、ガ、キ など)。その下に製品名と、メーカー・コンテナのバッジ

ギアの一覧のカード表示。画像のないギアは、頭文字のカードになる

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

キャンプ一覧のカード表示。右上に「リスト」「カード」の切り替え。カードが2つ並び、左は画像付きで「みなみ高原キャンプ場(架空)」、予定期間、プラン名、進み具合「15 / 45」と棒。右は画像がなく頭文字「か」のカードで、「かわべりオートキャンプ場(架空)」、予定期間、実績、プラン名、進み具合「33 / 33」と棒

キャンプの一覧のカード表示。進み具合は、数と細い棒で出す


表示の形は、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列で並ぶ。頭文字のカードの中に、緑のグラデーションの画像が付いたカードが1つある。長い製品名は2行に折れている

スマホの幅では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 は、まとめて本番にリリースしています。

次は、キャンプ場の情報を、過去のキャンプから引用できるようにする予定です。キャンプ場を別のテーブル(マスタ)で管理する案をやめた経緯も、そのときに書きます。

← トップページに戻る