家計簿アプリを作る #20:flowbite-svelte コンポーネントで UI を統一する

家計簿アプリ作成シリーズの第20回です。今回は全ページのUIを flowbite-svelte コンポーネントに置き換えて統一します。

対象ページ

  • ログイン・サインアップページ
  • 収支一覧ページ
  • 収支登録ページ
  • 収支編集ページ

使用コンポーネント

import {
  Input, Label, Select, Button, A,
  Table, TableHead, TableHeadCell, TableBody, TableBodyRow, TableBodyCell,
  Card, Badge, Alert, Helper, Datepicker,
} from "flowbite-svelte";
import { InfoCircleSolid } from "flowbite-svelte-icons";

ログイン・サインアップページ

<input><label>InputLabelButton に置き換えるだけでシンプルになります。

<Label for="email" class="mb-2">メールアドレス</Label>
<Input
  type="email"
  id="email"
  name="email"
  placeholder="name@example.com"
  class="placeholder-gray-500"
  required
/>

placeholder のテキスト色を変えたい場合は class="placeholder-gray-500" を追加します。入力テキストはデフォルトで黒になります。

エラー表示は Alert コンポーネントを使います。

{#if form?.error}
  <Alert color="red">
    {#snippet icon()}<InfoCircleSolid class="h-5 w-5" />{/snippet}
    {form.error}
  </Alert>
{/if}

収支一覧ページ

年月セレクト

Select コンポーネントに placeholder="" を渡すと「Choose option…」を非表示にできます。横幅は class="w-32" で固定します。

<Label>年月</Label>
<Select
  class="w-32"
  placeholder=""
  onchange={(e) => goto(`/?month=${e.currentTarget.value}`)}
>
  {#each data.months as month}
    <option value={month} selected={month === data.selectedMonth}>{month}</option>
  {/each}
</Select>

テーブル

<Table shadow hoverable={true}>
  <TableHead>
    <TableHeadCell>日付</TableHeadCell>
    ...
  </TableHead>
  <TableBody>
    {#each data.transactions as transaction}
      <TableBodyRow>
        <TableBodyCell>{transaction.date}</TableBodyCell>
        ...
      </TableBodyRow>
    {/each}
  </TableBody>
</Table>

集計カード

収入・支出・残高を3列のカードで表示します。

<div class="grid grid-cols-3 gap-4">
  <Card>
    <p class="text-sm text-gray-500">収入合計</p>
    <p class="text-xl font-bold text-green-600">{data.totalIncome.toLocaleString()}円</p>
  </Card>
  <Card>
    <p class="text-sm text-gray-500">支出合計</p>
    <p class="text-xl font-bold text-red-500">{data.totalExpense.toLocaleString()}円</p>
  </Card>
  <Card>
    <p class="text-sm text-gray-500">残高</p>
    <p class="text-xl font-bold">{data.balance.toLocaleString()}円</p>
  </Card>
</div>

ボタン・リンク

Buttonhref を渡すとリンクとして動作します。

<Button href={`/transactions/new?from=${data.selectedMonth ?? ""}`}>登録</Button>

収支登録ページ

カテゴリの初期選択

Selectplaceholder="" を設定しつつ、種類(収入/支出)切り替え時にカテゴリの先頭を自動選択するには $effect を使います。

let selectedType = $state("income");
let selectedCategory = $state(categoryMap["income"][0]);

$effect(() => {
  selectedCategory = categoryMap[selectedType][0];
});

Datepicker

Datepickername prop を持たないため、hidden input と組み合わせてフォーム送信します。

let selectedDate = $state<Date | undefined>(undefined);
<Datepicker bind:value={selectedDate} required />
<Input
  type="hidden"
  name="date"
  value={selectedDate ? selectedDate.toISOString().split("T")[0] : ""}
/>

toISOString().split("T")[0]YYYY-MM-DD フォーマットに変換します。

バリデーションエラー表示

フィールド直下のエラーは Helper コンポーネントを使います。

{#if form?.errors?.amount}
  <Helper class="mt-2" color="red">
    <span class="font-medium">{form.errors.amount}</span>
  </Helper>
{/if}

Input の value

Input コンポーネントに value を渡す際、型が合わない場合は as string でキャストします。

<Input
  type="number"
  name="amount"
  value={(form?.values?.amount ?? "") as string}
/>

収支編集ページ

編集ページでは既存データを初期値としてセットします。untrack で初期値の警告を回避します。

const { data, form } = $props();

let selectedType = $state(untrack(() => data.transaction.type));
let selectedCategory = $state(untrack(() => data.transaction.category));
let selectedDate = $state(untrack(() => new Date(data.transaction.date)));

カテゴリの初期選択(編集時)

登録ページと同じ $effect を使いますが、初回実行時はスキップして既存のカテゴリを保持します。

let isFirst = true;
$effect(() => {
  selectedType; // selectedTypeを依存関係として追跡
  if (isFirst) {
    isFirst = false;
    return;
  }
  selectedCategory = categoryMap[selectedType][0];
});

selectedType; の1行は、$effectselectedType を依存関係として追跡するために必要です。この行がないと、isFirsttrue のとき早期リターンで selectedType が参照されず、依存関係に登録されません。

まとめ

ポイント 内容
Select の placeholder 非表示 placeholder="" を渡す
Select の横幅 class="w-32" で固定
Button をリンクとして使う href prop を渡す
Datepicker のフォーム送信 hidden input と組み合わせる
Input の value 型エラー as string でキャスト
編集時の $effect 初回スキップ isFirst フラグで制御
$effect の依存関係追跡 変数を参照するだけで依存関係に登録される
← トップページに戻る