家計簿アプリを作る #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> を Input・Label・Button に置き換えるだけでシンプルになります。
<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>
ボタン・リンク
Button に href を渡すとリンクとして動作します。
<Button href={`/transactions/new?from=${data.selectedMonth ?? ""}`}>登録</Button>
収支登録ページ
カテゴリの初期選択
Select に placeholder="" を設定しつつ、種類(収入/支出)切り替え時にカテゴリの先頭を自動選択するには $effect を使います。
let selectedType = $state("income");
let selectedCategory = $state(categoryMap["income"][0]);
$effect(() => {
selectedCategory = categoryMap[selectedType][0];
});
Datepicker
Datepicker は name 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行は、$effect が selectedType を依存関係として追跡するために必要です。この行がないと、isFirst が true のとき早期リターンで selectedType が参照されず、依存関係に登録されません。
まとめ
| ポイント | 内容 |
|---|---|
Select の placeholder 非表示 |
placeholder="" を渡す |
Select の横幅 |
class="w-32" で固定 |
Button をリンクとして使う |
href prop を渡す |
Datepicker のフォーム送信 |
hidden input と組み合わせる |
Input の value 型エラー |
as string でキャスト |
編集時の $effect 初回スキップ |
isFirst フラグで制御 |
$effect の依存関係追跡 |
変数を参照するだけで依存関係に登録される |