0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンドの世界Advent Calendar 2024

Day 15

SolidJSの世界: UI構築とトランジション #3

Last updated at Posted at 2024-12-14

はじめに

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

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

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

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

フロントエンドの世界 2024」は普段 Next.js を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit), Remix ,SolidJS, Qwik(City)の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。

frontend-assort-2024-banner.png

もくじ

今回作るモノ

スクリーンショット 2024-12-16 0.16.10.png

今回は PokeAPI を用いたポケモン当てクイズアプリをSvelte で開発します。

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

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

ディレクトリ構成

tree実行結果
.
├── README.md
├── index.html
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── src
│   ├── App.tsx
│   ├── features
│   │   ├── form
│   │   │   ├── hooks
│   │   │   │   └── index.ts
│   │   │   └── types
│   │   │       └── index.ts
│   │   ├── pokemon
│   │   │   ├── data
│   │   │   │   └── index.ts
│   │   │   ├── types
│   │   │   │   └── index.ts
│   │   │   └── ui
│   │   │       └── Card
│   │   │           └── index.tsx
│   │   ├── time
│   │   │   └── hooks
│   │   │       └── index.ts
│   │   ├── ui
│   │   │   ├── CharButton
│   │   │   │   ├── index.tsx
│   │   │   │   └── type.ts
│   │   │   ├── Debugger
│   │   │   │   └── index.tsx
│   │   │   ├── LifeCounter
│   │   │   │   └── index.tsx
│   │   │   ├── Loader
│   │   │   │   └── index.tsx
│   │   │   ├── PrimaryButton
│   │   │   │   ├── index.tsx
│   │   │   │   └── type.ts
│   │   │   └── ProgressBar
│   │   │       ├── index.tsx
│   │   │       └── type.ts
│   │   └── utils
│   │       └── kana
│   │           └── index.ts
│   ├── index.css
│   ├── index.tsx
│   └── views
│       ├── GamePlayView
│       │   ├── index.tsx
│       │   └── type.ts
│       └── GameResultView
│           ├── index.tsx
│           └── type.ts
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts

機能の型定義

PokeAPI のレスポンス

features 配下にポケモン API の取得レスポンス型を定義します。

(参考: PokeAPI)

features/pokemon/types/index.ts
export type SpecieResponse = {
  names: {
    language: {
      name: string;
    };
    name: string;
  }[];
};

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

export type PokemonType = SpecieResponse & PokemonResponse;

回答入力フォーム

回答欄と選択肢で共通して使う型を定義します。

features/form/types/index.ts
export type FormType = {
  char: string;
  index: number;
};

export type FormValues = {
  options: FormType[];
  displayTexts: FormType[];
};

モックページの実装

まずは固定のデータで UI を実装します。

リザルト画面

React で慣れ親しんだ JSX 構文でリザルト画面を実装します。

views/GameResultView/type.tsx
// propsの型定義
export type GameResultViewProps = {
  score: number;
  onRetry: () => void;
};
views/GameResultView/index.tsx
import { GameResultViewProps } from "./type";

export const GameResultView = ({ score, onRetry }: GameResultViewProps) => {
  return (
    <section class="h-full w-full flex items-center justify-center">
      <div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
        <h2 class="text-center text-slate-500">スコア</h2>
        <p class="text-center text-2xl">
          {score}
          <span class="text-sm"></span>
        </p>
        {/* PrimaryButton */}
        <button
          onClick={onRetry}
          class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
        >
          もう一度プレイする
        </button>
      </div>
    </section>
  );
};

プレイ画面

同じ要領でプレイ画面も実装します。

views/GamePlayView/type.ts
// propsの型定義
import { FormType } from "../../features/form/types";
import { PokemonType } from "../../features/pokemon/types";

export type GamePlayViewProps = {
  displayTexts: FormType[];
  options: FormType[];
  lifeCounts: number;
  pokemon: PokemonType;
  onAddInput: (input: FormType) => void;
  onRemoveInput: (input: FormType) => void;
};
views/GamePlayView/index.tsx
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";

export const GamePlayView = (props: GamePlayViewProps) => {
  const displayTexts = () => props.displayTexts;
  const options = () => props.options;
  const lifeCounts = () => props.lifeCounts;
  const pokemon = () => props.pokemon;

  const life = Array.from({ length: lifeCounts() }).map((_, i) => i);

  return (
    <section class="flex flex-col items-center justify-center">
      {/* ProgressBar */}
      <div class="w-full h-3 bg-[#cbff7e]">
        <div
          class={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
          style={{ width: `${70}%` }}
        ></div>
      </div>

      {/* LifeCounter */}
      <div class="flex items-center gap-x-2 px-4 m-4 w-full">
        <For each={life}>{(_) => <div class="text-xl">❤️</div>}</For>
      </div>

      <h2 class="flex items-center justify-center text-2xl mt-8">
        このポケモンは誰?
      </h2>
      {/* Card */}
      <img
        src={pokemon()?.sprites.other.home.front_default ?? ""}
        alt="ポケモン"
        draggable={false}
        class="w-[350px] max-w-full h-fit mx-auto"
      />
      <div class="flex items-center gap-x-4 h-[70px]">
        <Show
          when={!!displayTexts().length}
          fallback={
            <span class="flex items-center justify-center text-gray-400">
              👇 クリックで名前を入力して下さい
            </span>
          }
        >
          <span>名前は</span>
          <For each={displayTexts()}>
            {(displayText) => (
              <button
                class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
                onClick={() => props.onRemoveInput(displayText)}
              >
                {displayText.char}
              </button>
            )}
          </For>
          <span>です</span>
        </Show>
      </div>
      <hr class="w-full" />
      <div class="flex items-center gap-x-4 h-[70px] m-2">
        <For each={options()}>
          {(option) => (
            <button
              class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
              onClick={() => props.onAddInput(option)}
            >
              {option.char}
            </button>
          )}
        </For>
      </div>
      {/* PrimaryButton */}
      <button
        onClick={() => {}}
        class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
      >
        回答する
      </button>
    </section>
  );
};

ルート画面

条件によって画面を変える制御処理を追加します。

Match コンポーネント
when 条件が true の場合にのみ要素を描画してくれるコンポーネントです。

Switch コンポーネント
複数の Match コンポーネントを Switch コンポーネントで囲む事で、最初に 条件 がtrueになったものだけ描画されるようになります。

(参考: <Switch> / <Match>)

ErrorBoundary
クライアントでエラーが throw された際にフォールバック画面を表示してくれるコンポーネントです。

(参考: ErrorBoundary)

App.tsx
import { ErrorBoundary, Match, Switch, type Component } from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { PokemonType } from "./features/pokemon/types";

const App: Component = () => {
  const displayTexts = [
    { char: "", index: 1 },
    { char: "", index: 2 },
    { char: "", index: 3 },
    { char: "", index: 4 },
    { char: "", index: 4 },
  ];

  // NOTE: ダミー入りの選択肢
  const options = [
    { char: "", index: 2 },
    { char: "", index: 3 },
    { char: "", index: 5 },
    { char: "", index: 3 },
    { char: "", index: 1 },
    { char: "", index: 6 },
    { char: "", index: 4 },
    { char: "", index: 7 },
  ];

  const pokemon: PokemonType = {
    sprites: {
      other: {
        home: {
          front_default:
            "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/3.png",
        },
      },
    },
    id: 1,
    name: "xxx",
    names: [{ language: { name: "xxx" }, name: "yyy" }],
  };

  // NOTE: API呼び出し時に置き換え
  const isLoading = false;

  const gameState = "START" as "START" | "END";
  const lifeCounts = 3;

  return (
    // エラー発生時はフォールバックを表示フォールバックを表示
    <ErrorBoundary fallback={<p>Error</p>}>
      <Switch>
        // NOTE: ロード中はLoaderを表示
        <Match when={isLoading}>
          <div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
            <Loader />
          </div>
        </Match>
        // NOTE: ゲーム終了状態の場合はリザルト画面を表示
        <Match when={gameState === "END"}>
          <GameResultView score={3} onRetry={() => {}} />
        </Match>
        // NOTE: ゲームが開始状態の場合はプレイ画面を表示
        <Match when={gameState === "START"}>
          <GamePlayView
            pokemon={pokemon}
            lifeCounts={lifeCounts}
            displayTexts={displayTexts}
            options={options}
            onAddInput={() => {}}
            onRemoveInput={() => {}}
          />
        </Match>
      </Switch>
    </ErrorBoundary>
  );
};

export default App;

共通コンポーネントの追加

ui/配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。
(参考: React - コンポーネントの定義)

CharButton

回答の入力・表示を行うボタンをコンポーネント化します。

ui/CharButton/type.ts
// 型定義
import { ReactNode } from "react";

export type CharButtonProps = {
  onClick: () => void;
  children: ReactNode;
};
ui/CharButton/index.tsx
// コンポーネント実態
import { CharButtonProps } from "./type";

export const CharButton = ({ onClick, children }: CharButtonProps) => {
  return (
    <button
      class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
      onClick={onClick}
    >
      {children}
    </button>
  );
};

PrimaryButton

回答やリトライを担うプライマリーボタンをコンポーネント化します。

ui/PrimaryButton/type.tsx
import { ReactNode } from "react";

export type PrimaryButtonProps = {
  onClick: () => void;
  children: ReactNode;
};
ui/PrimaryButton/index.tsx
import { PrimaryButtonProps } from "./type";

export const PrimaryButton = ({ onClick, children }: PrimaryButtonProps) => {
  return (
    <button
      onClick={onClick}
      class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
    >
      {children}
    </button>
  );
};

LifeCounter

残りのライフ表示をコンポーネント化します。

ui/LifeCounter/index.tsx
export const LifeCounter = (props: { lifeCount: number }) => {
  const lifeCount = () => props.lifeCount;

  return (
    <div class="flex items-center gap-x-2 px-4 m-4 w-full">
      {Array.from({ length: lifeCount() }).map((_) => (
        <div class="text-xl">❤️</div>
      ))}
    </div>
  );
};

Loader

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

今回はSVG Backgroundsというサイトで生成したRipplesローダーの色だけ調整してそのまま使用します。

ui/Loader/index.tsx
export const Loader = () => {
  return (
    <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>
  );
};

ProgressBar

ゲームの制限時間表示をコンポーネント化します。

classList を用いると条件に一致したクラス定義を動的に行えます。

(参考: classList)

ui/ProgressBar/type.ts
export type ProgressBarProps = {
  value: number;
  maxValue: number;
};
ui/ProgressBar/index.ts
import { ProgressBarProps } from "./type";

export const ProgressBar = ({ value, maxValue }: ProgressBarProps) => {
  const progress = (value / maxValue) * 100;

  const isBellowMax = value < maxValue;
  // NOTE: 1000ms分のdelayがあるので-1を終了時間にする
  const isAboveMin = value > -1;
  const isBetweenRange = isAboveMin && isBellowMax;

  return (
    <div class="w-full h-3 bg-[#cbff7e]">
      <div
        classList={{
          ["h-full rounded-r-md bg-[yellowgreen] ease-linear"]: true,
          ["duration-1000"]: isBetweenRange,
        }}
        style={{ width: `${progress}%` }}
      ></div>
    </div>
  );
};

Debugger

開発時の値確認用コンポーネントを追加します。

ui/Debugger/index.tsx
export const Debugger = ({
  data,
}: {
  data: Record<string, string | number | undefined>;
}) => {
  if (process.env.NODE_ENV !== "development") return null;

  return (
    <div class="absolute top-0 right-0 bg-black bg-opacity-50 text-white">
      {Object.keys(data).map((key) => (
        <dl class="flex items-center gap-x-2">
          <dt>{key} :</dt>
          <dd>{data[key]}</dd>
        </dl>
      ))}
    </div>
  );
};

その他のコンポーネント

Card

取得したポケモンの画像表示用 img タグをコンポーネント化します。
altが主にポケモンに依存しそうなのでpokemonというfeature配下に追加します。

今回のテーマでは無いので雑な設計はご容赦いただけると幸いです 🙇‍♂️

features/pokemon/ui/Card/index.tsx
export const Card = ({ src }: { src: string }) => {
  return (
    <img
      src={src}
      alt="ポケモン"
      draggable={false}
      class="w-[350px] max-w-full h-fit mx-auto"
    />
  );
};

共通コンポーネント呼び出し

リザルト画面

PrimaryButton をインポートします。

views/GameResultView/index.tsx
+ import { PrimaryButton } from "../../features/ui/PrimaryButton";
import { GameResultViewProps } from "./type";

export const GameResultView = ({ score, onRetry }: GameResultViewProps) => {
  return (
    <section class="h-full w-full flex items-center justify-center">
      <div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
        <h2 class="text-center text-slate-500">スコア</h2>
        <p class="text-center text-2xl">
          {score}
          <span class="text-sm"></span>
        </p>
-       {/* PrimaryButton */}
-       <button
-         onClick={onRetry}
-         class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-       >
-         もう一度プレイする
-       </button>
+       <PrimaryButton onClick={onRetry}>もう一度プレイする</PrimaryButton>
      </div>
    </section>
  );
};

プレイ画面

上で作成した共通コンポーネントを GamePlayView でインポートします。

views/GamePlayView/index.tsx
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";
+ import { ProgressBar } from "../../features/ui/ProgressBar";
+ import { LifeCounter } from "../../features/ui/LifeCounter";
+ import { Card } from "../../features/pokemon/ui/Card";
+ import { CharButton } from "../../features/ui/CharButton";
+ import { PrimaryButton } from "../../features/ui/PrimaryButton";

export const GamePlayView = (props: GamePlayViewProps) => {
  const displayTexts = () => props.displayTexts;
  const options = () => props.options;
  const lifeCounts = () => props.lifeCounts;
  const pokemon = () => props.pokemon;

  const life = Array.from({ length: lifeCounts() }).map((_, i) => i);

  return (
    <section class="flex flex-col items-center justify-center">
-     {/* ProgressBar */}
-     <div class="w-full h-3 bg-[#cbff7e]">
-       <div
-         class={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
-         style={{ width: `${70}%` }}
-       ></div>
-     </div>
+     <ProgressBar value={70} maxValue={100} />

-     {/* LifeCounter */}
-     <div class="flex items-center gap-x-2 px-4 m-4 w-full">
-       <For each={life}>{(_) => <div class="text-xl">❤️</div>}</For>
-     </div>
+     <LifeCounter lifeCount={lifeCounts()}/>

      <h2 class="flex items-center justify-center text-2xl mt-8">
        このポケモンは誰?
      </h2>
-     {/* Card */}
-     <img
-       src={pokemon()?.sprites.other.home.front_default ?? ""}
-       alt="ポケモン"
-       draggable={false}
-       class="w-[350px] max-w-full h-fit mx-auto"
-     />
+     <Card src={pokemon()?.sprites.other.home.front_default ?? ""} />
      <div class="flex items-center gap-x-4 h-[70px]">
        <Show
          when={!!displayTexts().length}
          fallback={
            <span class="flex items-center justify-center text-gray-400">
              👇 クリックで名前を入力して下さい
            </span>
          }
        >
          <span>名前は</span>
          <For each={displayTexts()}>
            {(displayText) => (
-             <button
-               class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-               onClick={() => props.onRemoveInput(displayText)}
-             >
-               {displayText.char}
-             </button>
+             <CharButton onClick={() => props.onRemoveInput(displayText)}>
+               {displayText.char}
+             </CharButton>
            )}
          </For>
          <span>です</span>
        </Show>
      </div>
      <hr class="w-full" />
      <div class="flex items-center gap-x-4 h-[70px] m-2">
        <For each={options()}>
          {(option) => (
-           <button
-             class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-             onClick={() => props.onAddInput(option)}
-           >
-             {option.char}
-           </button>
+           <CharButton onClick={() => props.onRemoveInput(displayText)}>
+             {displayText.char}
+           </CharButton>
          )}
        </For>
      </div>
-      {/* PrimaryButton */}
-     <button
-       onClick={() => {}}
-       class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-     >
-       回答する
-     </button>
+     <PrimaryButton onClick={() => {}}>
+       回答する
+     </PrimaryButton>
    </section>
  );
};

最終的なコード

App.tsx
import { ErrorBoundary, Match, Switch, type Component } from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { PokemonType } from "./features/pokemon/types";

const App: Component = () => {
  const displayTexts = [
    { char: "", index: 1 },
    { char: "", index: 2 },
    { char: "", index: 3 },
    { char: "", index: 4 },
    { char: "", index: 4 },
  ];

  // NOTE: ダミー入りの選択肢
  const options = [
    { char: "", index: 2 },
    { char: "", index: 3 },
    { char: "", index: 5 },
    { char: "", index: 3 },
    { char: "", index: 1 },
    { char: "", index: 6 },
    { char: "", index: 4 },
    { char: "", index: 7 },
  ];

  const pokemon: PokemonType = {
    sprites: {
      other: {
        home: {
          front_default:
            "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/3.png",
        },
      },
    },
    id: 1,
    name: "xxx",
    names: [{ language: { name: "xxx" }, name: "yyy" }],
  };
  const isLoading = false;

  const gameState = "START" as "START" | "END";
  const lifeCounts = 3;

  return (
    <ErrorBoundary fallback={<p>Error</p>}>
      <Switch>
        <Match when={isLoading}>
          <div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
            <Loader />
          </div>
        </Match>
        <Match when={gameState === "END"}>
          <GameResultView score={3} onRetry={() => {}} />
        </Match>
        <Match when={gameState === "START"}>
          <GamePlayView
            pokemon={pokemon}
            lifeCounts={lifeCounts}
            displayTexts={displayTexts}
            options={options}
            onAddInput={() => {}}
            onRemoveInput={() => {}}
          />
        </Match>
      </Switch>
    </ErrorBoundary>
  );
};

export default App;

スクリーンショット 2024-12-16 0.21.14.png

おわりに

For や Match コンポーネントを使う事で、制御処理を非常に見やすく書く事が出来て感動しました。

また標準で ErrorBoundary が使えるのも便利だと感じました。

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


この記事は フロントエンドの世界 Advent Calendar 2024の 15 記事目です。
次の記事はこちら SolidJS の世界: データ取得と状態管理 #4

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?