Svelte(Kit)の世界: UI構築とトランジション #3


## はじめに

はじめまして、WEB フロントエンドエンジニアの nuintee です。

この度かねてより関心があった Svelte に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。

Svelte に少しでも興味のある方は、ぜひご覧ください。

## フロントエンドの世界 2024 について

「[フロントエンドの世界 2024](https://qiita.com/advent-calendar/2024/frontend-assort-2024)」は普段 [`Next.js`](https://nextjs.org/) を書いている筆者が、同じフロントエンドライブラリである [`Svelte(Kit)`](https://svelte.jp/docs/introduction), [`Remix`](https://remix.run/docs/en/main) ,[`SolidJS`](https://docs.solidjs.com/), [`Qwik(City)`](https://qwik.dev/)の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。

![frontend-assort-2024-banner.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/7666d209-1aef-9ce5-c298-d8e60847e830.png)

## もくじ

- [はじめに](#はじめに)
- [フロントエンドの世界 2024 について](#フロントエンドの世界-2024-について)
- [もくじ](#もくじ)
- [今回作るモノ](#今回作るモノ)
- [ディレクトリ構成](#ディレクトリ構成)
- [モックページの実装](#モックページの実装)
- [リアクティビティ](#リアクティビティ)
- [コンポーネント](#コンポーネント)
- [条件付きレンダリング](#条件付きレンダリング)
- [トランジション](#トランジション)
- [おわりに](#おわりに)

## 今回作るモノ

https://81ab05e0.frontend-assort-2024.pages.dev/

![complete.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/46a11eef-3451-2f05-9a75-2162a70c87c7.gif)

今回は [PokeAPI](https://pokeapi.co/) を用いたポケモン当てクイズアプリを`Svelte` で開発します。

機能的には以下の通りです。

- ランダム出題機能
- 回答判定機能
- ライフ管理
- 制限時間管理
- リザルト機能

## ディレクトリ構成

今回は features をベースとしたディレクトリ構成にしました。

(参考: [bulletproof-react](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md))

```shell
tree -I node_modules
```

```txt:tree実行結果
.
├── README.md
├── package.json
├── postcss.config.js
├── src
│   ├── app.css
│   ├── app.d.ts
│   ├── app.html
│   ├── features
│   │   └── pokemon
│   │       ├── types
│   │       │   └── index.ts
│   │       ├── ui
│   │       │   └── Card.svelte
│   │       └── utils
│   │           └── fetch.ts
│   ├── lib
│   │   └── index.ts
│   ├── routes
│   │   ├── +layout.svelte
│   │   ├── +page.svelte
│   │   └── a
│   │       └── +page.svelte
│   ├── stores
│   │   ├── form.ts
│   │   ├── game.ts
│   │   └── timer.ts
│   ├── types
│   │   └── form.ts
│   ├── ui
│   │   ├── CharButton.svelte
│   │   ├── PrimaryButton.svelte
│   │   ├── ProgressBar.svelte
│   │   └── Spinner.svelte
│   └── utils
│       └── kana.ts
├── static
│   └── favicon.png
├── svelte.config.js
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts
```

## モックページの実装

まずは固定値で UI を実装します。

### Typescript の利用

`script` タグの `lang` に `ts` を指定する事で、`Typescript` が使えるようになります。

※ 前提としてプロジェクトセットアップ時に Typescript を有効化している必要があります。
(参考: [Typescript の有効化](https://qiita.com/nuinteedev/private/c240b8f49f326ceea7e1#%E5%AF%BE%E8%A9%B1%E5%9E%8B%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97:~:text=%3E%20SvelteKit%20minimal-,Typescript%20%E3%81%AE%E6%9C%89%E5%8A%B9%E5%8C%96,-%E4%BB%8A%E5%9B%9E%E3%81%AF%20Typescript))

```svelte:+page.svelte
<script lang="ts">
...
</script>
```

### each ブロック

繰り返したい要素を `each` ブロックで囲む事でループによるレンダリングが行えます。

```svelte:+page.svelte
<!-- #each [配列] as [繰り返したアイテム] (キー) -->
{#each mockDisplayTexts as display (display.id)}
  <button class = "char__input">{display.char}</button>
{/each}
```

### スタイリング

`.svelte`ファイル内に`style`タグを追加し、スタイリングを行います。

:::note info
2023 年 12 月に公開された CSS 標準の入れ子セレクターを使用しています。
:::

(参考: [& 入れ子セレクター](https://developer.mozilla.org/ja/docs/Web/CSS/Nesting_selector))

```svelte:+page.svelte
...
<style scoped>
  /* 一部省略 */
  ...
  .page {
    height: 100%;
    width: 100%;
  }
  /* 一部省略 */
  ...
  .primary__button {
    padding: 1rem 2rem;
    background-color: greenyellow;
    border-radius: 10px;
    font-weight: 500;
    max-width: 100%;
    width: 350px;
    &:hover {
      opacity: 0.75;
    }
    &:active {
      opacity: 0.5;
    }
  }
</style>
```

### コード全体

```svelte:+page.svelte - コード全体
<script lang="ts">
  type AnswerType = {
    id: string;
    char: string;
    index: number;
  };

  type PokemonResponse = {
    id: number;
    name: string;
    sprites: {
      other: {
        home: {
          front_default: string;
        };
      };
    };
  };

  const mockPokemonData: PokemonResponse = {
    id: 25,
    name: "ピカチュウ",
    sprites: {
      other: {
        home: {
          front_default:
            "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/25.png",
        },
      },
    },
  };

  const mockDisplayTexts: AnswerType[] = [
    { char: "ピ", id: "1", index: 0 },
    { char: "カ", id: "2", index: 1 },
    { char: "チ", id: "3", index: 2 },
    { char: "ュ", id: "4", index: 3 },
  ];

  const mockOptions: AnswerType[] = [
    { char: "ピ", id: "1", index: 0 },
    { char: "カ", id: "2", index: 1 },
    { char: "チ", id: "3", index: 2 },
    { char: "ュ", id: "4", index: 3 },
    { char: "ウ", id: "5", index: 4 },
  ];

  const MAX_TIME = 15;
  const currentTime = 10;
</script>

<main class="page">
  <!-- ProgressBar コンポーネント -->
  <div class="w-full h-3 bg-[#cbff7e]">
    <div
      class="h-full rounded-r-md bg-[yellowgreen]"
      style="width: {`${(currentTime / MAX_TIME) * 100}%`}"
    ></div>
  </div>

  <div class="flex items-center gap-x-2 m-4">
    <div class="text-xl">❤️❤️❤️</div>
  </div>

  <h2 class="flex items-center justify-center text-2xl mt-8">
    このポケモンは誰？
  </h2>

  <!-- Card コンポーネント -->
  <div class="pokemon__card">
    <img
      src={mockPokemonData.sprites.other.home.front_default}
      alt="ポケモン"
      draggable="false"
    />
  </div>

  <div>
    <div class="display__container">
      <div class="display__innerContainer">
        <span>名前は</span>
        {#each mockDisplayTexts as display (display.id)}
          <button class="char__input">{display.char}</button>
        {/each}
        <span>です</span>
      </div>
    </div>
    <hr />

    <div class="input__container">
      {#each mockOptions as option (option.id)}
        <button class="char__input">{option.char}</button>
      {/each}
    </div>
  </div>

  <!-- PrimaryButton コンポーネント -->
  <div class="answer__buttonContainer">
    <button class="primary__button">回答する</button>
  </div>
</main>

<style scoped>
  .page {
    height: 100%;
    width: 100%;
  }
  .display__container {
    display: flex;
    flex-direction: column;
    justify-content: center;
    height: 70px;
    margin-top: 1rem;
  }
  .display__innerContainer {
    display: flex;
    align-items: center;
    justify-content: center;
    column-gap: 1rem;
  }
  .input__container {
    display: flex;
    flex-wrap: wrap;
    max-width: 100%;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    gap: 1rem;
  }
  .answer__buttonContainer {
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .page__spinner {
    height: 10rem;
    width: 10rem;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
  .pokemon__card {
    width: 350px;
    max-width: 100%;
    height: fit-content;
    margin-inline: auto;
  }
  .char__input {
    padding: 1rem 1.5rem;
    background-color: ghostwhite;
    border-radius: 10px;
    &:hover {
      opacity: 0.75;
    }
    &:active {
      opacity: 0.5;
    }
  }
  .primary__button {
    padding: 1rem 2rem;
    background-color: greenyellow;
    border-radius: 10px;
    font-weight: 500;
    max-width: 100%;
    width: 350px;
    &:hover {
      opacity: 0.75;
    }
    &:active {
      opacity: 0.5;
    }
  }
</style>
```

ここまでの実装で以下の表示になっていると思います。
まだボタンを押しても何も起こりません。

![svelte-fixed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/20042ebf-cf2b-1dc5-7162-ad02927c8d43.png)

## リアクティビティ

次は固定値が動的に変わるようにします。

(参考: [Reactivity](https://learn.svelte.jp/tutorial/reactive-declarations))

### let による変数宣言

動的に変化させたい値の変数宣言を`const`から`let`に変更します。

```diff_typescript:+page.svelte
...
- const mockDisplayTexts: AnswerType[] = [
+ let mockDisplayTexts: AnswerType[] = [
  { char: "ピ", id: "1", index: 0 },
  { char: "カ", id: "2", index: 1 },
  { char: "チ", id: "3", index: 2 },
  { char: "ュ", id: "4", index: 3 },
];

- const mockOptions: AnswerType[] = [
+ let mockOptions: AnswerType[] = [
  { char: "ピ", id: "1", index: 0 },
  { char: "カ", id: "2", index: 1 },
  { char: "チ", id: "3", index: 2 },
  { char: "ュ", id: "4", index: 3 },
  { char: "ウ", id: "5", index: 4 },
];
```

### 再代入による値変化

先ほど let で定義した値の変化を画面に反映するには再代入を行います。
ここでは配列操作を行うメソッドを定義します。

```ts:+page.svelte
// 入力した選択肢を回答欄に追加する処理
export const addInput = (char: AnswerType) => {
  mockDisplayTexts = [...mockDisplayTexts, char];
};

// 入力した選択肢を回答欄から削除する処理
export const removeInput = (id: string) => {
  mockDisplayTexts = mockDisplayTexts.filter((display) => display.id !== id);
};
```

### クリックイベントとの紐付け

上で定義した`addInput`と`removeInput`メソッドを各要素のクリックイベントに紐づけます。

```diff_svelte:+page.svelte
<!-- 入力された回答の表示 -->
{#each mockDisplayTexts as display (display.id)}
    <button
      class="char__input"
+     on:click={() => removeInput(display.id)}
    >
      {display.char}
    </button>
{/each}

...

<!-- カナ文字入力を表示 -->
{#each mockOptions as option (option.id)}
    <button
      class="char__input"
+     on:click={() => addInput(option)}
    >
      {option.char}
    </button>
{/each}
```

これで入力された選択肢と回答欄の値を動的に表示する事ができました。

![svelte-3-dynamic.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/d36db960-63fa-a72a-c961-cb9e9de3be71.gif)

## コンポーネント

同一ページに記述している内容をコンポーネントとして切り出していきます。

### CharButton

回答欄で表示するボタンをコンポーネント化します。

**props 定義**
`props` を定義するには `let` 変数宣言時に `export` を付けます。
例: `export let [prop名];`

**小要素の定義**
`React` で言うところの `Children` ですが、これを定義するには`slot`タグを追加します。

このコンポーネントでラップしている要素が表示されます。

例: `<CharButton>test</CharButton>` とした場合 `slot`には "test"という文字が入ります。

```svelte:components/ui/CharButton.svelte
<script>
  // props定義
  export let onClick;
</script>

<button
  class="char__input"
  on:click={onClick}
>
<!-- 小要素の定義 -->
  <slot />
</button>

<style scoped>
  .char__input {
    padding: 1rem 1.5rem;
    background-color: ghostwhite;
    border-radius: 10px;
    &:hover {
      opacity: 0.75;
    }
    &:active {
      opacity: 0.5;
    }
  }
</style>
```

### PrimaryButton

汎用的に使用するプライマリーボタンをコンポーネント化します。
`style` 以外は上述の[`CharButton`](#charbutton) と全く同じ内容なので説明は割愛します。

```svelte
<script>
  // props定義
  export let onClick;
</script>

<button
  class="primary__button"
  on:click={onClick}
>
<!-- 小要素の定義 -->
  <slot />
</button>

<style scoped>
  .primary__button {
    padding: 1rem 2rem;
    background-color: greenyellow;
    border-radius: 10px;
    font-weight: 500;
    max-width: 100%;
    width: 350px;
    &:hover {
      opacity: 0.75;
    }
    &:active {
      opacity: 0.5;
    }
  }
</style>
```

### ProgressBar

ゲームの制限時間を表示する`ProgressBar`コンポーネントを切り出します。

**リアクティブ宣言**
`$:`で始まるリアクティブ宣言を行うと複数の動的値をもとに値の再計算を行う事ができます。

今回のケースだと`maxTime`と`time`の値変化に合わせて`progress`の値を再計算しています。

(参考: [リアクティブ宣言](https://learn.svelte.jp/tutorial/reactive-declarations))

```svelte:components/ui/ProgressBar.svelte
<script>
  // props定義
  export let maxTime;
  export let time;

  // リアクティブ宣言
  $: progress = time / maxTime * 100
</script>

<!-- こちらはTailwindCSSで定義 -->
<div class="w-full h-3 bg-[#cbff7e]">
  <div class="h-full rounded-r-md bg-[yellowgreen]" style="width: {`${progress}%`}"></div>
</div>
```

### Spinner

ローディング時に表示するアニメーション SVG をコンポーネント化します。

今回は[SVG Backgrounds](https://www.svgbackgrounds.com/elements/animated-svg-preloaders/)というサイトで生成した`Ripples`ローダーの色だけ調整してそのまま使用します。

```svelte:components/ui/Spinner.svelte
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"
  ><circle
    fill="none"
    stroke-opacity="1"
    stroke="#9E9E9E"
    stroke-width=".5"
    cx="100"
    cy="100"
    r="0"
    ><animate
      attributeName="r"
      calcMode="spline"
      dur="2"
      values="1;80"
      keyTimes="0;1"
      keySplines="0 .2 .5 1"
      repeatCount="indefinite"
    ></animate><animate
      attributeName="stroke-width"
      calcMode="spline"
      dur="2"
      values="0;25"
      keyTimes="0;1"
      keySplines="0 .2 .5 1"
      repeatCount="indefinite"
    ></animate><animate
      attributeName="stroke-opacity"
      calcMode="spline"
      dur="2"
      values="1;0"
      keyTimes="0;1"
      keySplines="0 .2 .5 1"
      repeatCount="indefinite"
    ></animate></circle
  ></svg
>
```

### コンポーネントの呼び出し

```diff_svelte:+page.svelte
<script lang="ts">
+ import ProgressBar from "../ui/ProgressBar.svelte"
+ import CharButton from "../ui/CharButton.svelte";
+ import PrimaryButton from "../ui/PrimaryButton.svelte";
+ import Spinner from "../ui/Spinner.svelte";
  ...
</script>
...
<main class="page">
-  <!-- ProgressBar コンポーネント -->
-  <div class="w-full h-3 bg-[#cbff7e]">
-    <div
-      class="h-full rounded-r-md bg-[yellowgreen]"
-      style="width: {`${(currentTime / MAX_TIME) * 100}%`}"
-    ></div>
-  </div>
+  <ProgressBar maxTime={MAX_TIME} time={currentTime} />
...
  <div>
    <div class="display__container">
      <div class="display__innerContainer">
        <span>名前は</span>
        {#each mockDisplayTexts as display (display.id)}
-          <button class="char__input" on:click = {() => removeInput(display.id)}>{display.char}</button>
+          <CharInput onClick = {() => removeInput(display.id)}>{display.char}</CharInput>
        {/each}
        <span>です</span>
      </div>
    </div>
    <hr />

    <div class="input__container">
      {#each mockOptions as option (option.id)}
-        <button class="char__input" on:click = {() => addInput(option)}>{option.char}</button>
+        <CharButton onClick = {() => addInput(option)}>{option.char}</CharButton>
      {/each}
    </div>
  </div>

-  <!-- PrimaryButton コンポーネント -->
  <div class="answer__buttonContainer">
-   <button class="primary__button">回答する</button>
+   <PrimaryButton>回答する</PrimaryButton>
  </div>
</main>

<style scoped>
/* ...省略 */
-  .char__input {
-    padding: 1rem 1.5rem;
-    background-color: ghostwhite;
-    border-radius: 10px;
-    &:hover {
-      opacity: 0.75;
-    }
-    &:active {
-      opacity: 0.5;
-    }
-  }
-  .primary__button {
-    padding: 1rem 2rem;
-    background-color: greenyellow;
-    border-radius: 10px;
-    font-weight: 500;
-    max-width: 100%;
-    width: 350px;
-    &:hover {
-      opacity: 0.75;
-    }
-    &:active {
-      opacity: 0.5;
-    }
-  }
</style>
```

## 条件付きレンダリング

ゲームの状態によって表示を出し分ける実装を追加します。

### ゲームステートの追加

まずはゲームの状態を管理するオブジェクトを定義します。

```svelte:+page.svelte
<script lang="ts">
// ゲームステートの型
type GameState = {
  state: "START" | "END"; // START: 開始状態, END: 終了状態
  life: number; // ライフ
  score: number; // スコア
  round: number; // ラウンド数
};

// 後ほど再利用するので初期状態を定数化
export const INIT_GAME_STATE: GameState = {
  state: "START",
  life: 3,
  score: 0,
  round: 1,
};

export const gameState = INIT_GAME_STATE;
</script>
...
```

### ゲームステートによって画面を出しわけ

`gameState` が `START` の場合は先ほどの `main`の内容を表示し、それ以外の場合 (`END`)はリザルト画面を表示させるように分岐させます。

```diff_svelte:+page.svelte
+ {#if gameState.state === "START"}
  <!-- mainタグとその中身全てをSTARTモードの条件下に入れる -->
  <main class="page">
    <ProgressBar maxTime={MAX_TIME} time={currentTime} />

    <div class="flex items-center gap-x-2 m-4">
      <div class="text-xl">❤️❤️❤️</div>
    </div>

    <h2 class="flex items-center justify-center text-2xl mt-8">
      このポケモンは誰？
    </h2>

    <div class="pokemon__card">
      <img
        src={mockPokemonData.sprites.other.home.front_default}
        alt="ポケモン"
        draggable="false"
      />
    </div>

    <div>
      <div class="display__container">
        <div class="display__innerContainer">
          <span>名前は</span>
          {#each mockDisplayTexts as display (display.id)}
            <CharButton onClick={() => removeInput(display.id)}
              >{display.char}</CharButton
            >
          {/each}
          <span>です</span>
        </div>
      </div>

      <hr />

      <div class="input__container">
        {#each mockOptions as option (option.id)}
          <div>
            <CharButton onClick={() => addInput(option)}>{option.char}</  CharButton
            >
          </div>
        {/each}
      </div>
    </div>

    <div class="answer__buttonContainer">
      <button class="primary__button">回答する</button>
    </div>
  </main>
+   {:else}
+ <!-- リザルト画面を追加 -->
+      <section class="page--centered">
+        <div class="result__container">
+          <h2 class="text-center text-slate-500">スコア</h2>
+          <p class="text-center text-2xl">
+            {gameState.score}
+            <span class="text-sm">点</span>
+          </p>
+          <PrimaryButton onClick = {() => {}}>もう一度プレイする</PrimaryButton>
+        </div>
+      </section>
+ {/if}

<style scoped>
...
+ /* リザルト画面のCSS */
+  .page--centered {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .result__container {
+    display: flex;
+    flex-direction: column;
+    row-gap: 1rem;
+    border: 1px solid gainsboro;
+    border-radius: 10px;
+    padding: 1rem;
+  }
...
</style>
```

## トランジション

Svelte の特徴でもあるアニメーションを活かして挙動に動きをつけます。

### 回答欄と入力選択肢の同期

まずは入力したカナ文字がが選択肢から消え、回答欄に追加されるようにします。

上述の[リアクティブ宣言](#progressbar)で `mockOptions` と `mockDisplayTexts` の変更を感知しつつ選択肢のオブジェクト配列を整形します。

```diff_svelte:+page.svelte
<script lang="ts">
...
+ // $: リアクティブ宣言
+ $: options = mockOptions.filter(
+    (option) =>
+    　// mockDisplayTextsに含まれないmockOptionsでフィルタリング
+      !mockDisplayTexts.some(
+        // optionとdisplayオブジェクトの同一比較
+        (display) => JSON.stringify(option) === JSON.stringify+(display)
+      )
+  );
</script>
...
      <div class="input__container">
-        {#each mockOptions as option (option.id)}
+        {#each options as option (option.id)}
          <div>
            <CharButton onClick={() => addInput(option)}
              >{option.char}</CharButton
            >
          </div>
        {/each}
      </div>
```

ここまでで現状以下のような挙動になっているはずです。

![input-sync.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/d3bf3cde-9178-fd64-4042-a50e9bb5d6cd.gif)

### crossfade の利用

ここでようやくアニメーションを追加します。

リスト内の要素に対して `in:receive (要素出現時のトランジション)`, `out:send (要素非表示時のトランジション)`を設定します。

また `animation:flip`を設定すると、リストへの要素追加・削除がスムーズな挙動になります。

(参考: [Deferred Transitions](https://learn.svelte.jp/tutorial/deferred-transitions))

```diff_svelte:+page.svelte
<script lang="ts">
...
+  import { flip } from "svelte/animate";
+  import { crossfade } from "svelte/transition";
...
+  const [send, receive] = crossfade({});
...
</script>
...
  {#each displayTexts as display (display.id)}
+    <div
+      animate:flip
+      in:receive={{ key: display.id }}
+      out:send={{ key: display.id }}
    >
      <CharButton onClick={() => removeInput(did)}
        >{display.char}</CharButton
      >
+    </div>
  {/each}
...
  {#each options as option (option.id)}
+    <div
+      animate:flip
+      in:receive={{ key: option.id }}
+      out:send={{ key: option.id }}
    >
      <CharButton onClick={() => addInput(option)}
        >{option.char}</CharButton
      >
+    </div>
  {/each}
```

これまでのステップで、
以下のようなアニメーション付き回答入力処理が実現できました。

![svelte-animated.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/d39450a3-97b5-c01d-358c-3e0dc80dc1de.gif)

## おわりに

`svelte/transition`と`svelte/animation`を利用するとこんなに簡単に横断的なトランジションが実現できるんだなと感動しました。

あと開発ステップを章毎に区切って記事化するのって結構難しいですね。
引き続き頑張ります。

また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします！

---

この記事は [フロントエンドの世界 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/frontend-assort-2024)の 4 記事目です。
次の記事はこちら [Svelte(Kit)の世界: データ取得と状態管理 #4](https://qiita.com/nuinteedev/private/82526d297b8fc113fed6)