Next.js+Tailwind CSS+Storybookでフロントエンド開発②

はじめに

前回まで

Next.js+Tailwind CSS+Storybookの環境構築を完了させました。
今回は各コンポーネントを用意していこうと思います。

  1. 環境構築
  2. Storybookで管理しながらコンポーネントを作成
  3. APIの呼び出し
  4. コンポーネントを組み合わせてレイアウト構築

完成図

今回は下記のコンポーネントを作ることにしました。
テキストボックス、ボタン、抽選パネルを使い回すことになりそうです。

  • ヘッダー
  • セレクトボックス
  • テキストボックス
  • ボタン
  • 抽選パネル

コンポーネントを作成する

今回はTailwind CSSでデザイン設定しつつ、Storybookで確認&管理してみます。

カスタムクラス、画像

カスタムクラスとしてmainBlue,mainLightGray,mainDarkGrayを用意しました。
共通カラーとして使います。

tailwind.css config

import type { Config } from "tailwindcss";

export default {
    content: [
        "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
        "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
        "./src/app/**/*.{js,ts,jsx,tsx,mdx}"],
    theme: {
        extend: {
            colors: {
                mainBlue: "#00459c",
                mainLightGray: "#b6b6b9",
                mainDarkGray: "#85868a",
            },
        },
    },
    plugins: [],
} satisfies Config;

画像

ロゴ画像を用意しました。
白文字&透過背景なので見えない!
抽選アプリのロゴ

ヘッダー

Header.tsx

import Image from "next/image";

export const Header = () => {
    return (
        <header className="w-full px-5 py-3  shadow-lg bg-mainBlue">
            <Image src="/images/logo.svg" width={217} height={48} alt="logo" />
        </header>
    );
};

Next.jsのImageコンポーネントを使ってロゴを表示させました。
設定したクラスは下記のCSS設定を意味します。

  • w-full → width: 100%
  • px-5, py-3 → padding: 20px 12px
  • shadow-lg → box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)
  • bg-mainBlue → background-color: #00459c

Header.stories

import type { Meta, StoryObj } from "@storybook/react";
import { Header } from "./Header";

const meta = {
    title: "Lottery/Header",
    component: Header,
    tags: ["autodocs"],
} satisfies Meta<typeof Header>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {};

渡すpropsが無いので、storyは1つです。

セレクトボックス

Select.tsx

import React, { useState }  from "react";

export interface SelectProps {
    name: string;
    options: { key: string, value: string, label: string }[];
    defaultValue: string;
    label?: string;
    onSelectChange?: (value: string) => void;
}

export const Select = ({ name, options = [], defaultValue, label = "", onSelectChange, ...props }: SelectProps) => {
    const [selected, setSelected] = useState(defaultValue);
    const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
        if (onSelectChange) {
            onSelectChange(event.target.value);
        }
        setSelected(event.target.value)
    };
    return (
        <div className="grid gap-y-1">
            <label htmlFor={name} className="text-sm text-mainDarkGray">
                {label}
            </label>
            <select value={selected} name={name} onChange={handleChange} className={["h-9","p-2", "rounded-md", "box-border", "border-solid", "border", "border-mainLightGray"].join(" ")} {...props}>
                {
                    options.map(option => {
                        return <option key={option.key} value={option.value}>{ option.label}</option>
                    })
                }
            </select>
        </div>
    );
};

ラベルを表示切替えできるようにしました。
大枠の<div>には下記のCSS設定をしています。

  • grid → display: grid
  • gap-y-1 → row-gap: 4px

``の枠線には3つのクラスが必要でした。

  • border-solid → border-solid
  • border → border-width: 1px
  • border-mainLightGray → border-color: #b6b6b9

Select.stories.ts

import type { Meta, StoryObj } from "@storybook/react";
import { Select } from "./Select";

const meta = {
    title: "Lottery/Select",
    component: Select,
    parameters: {
        layout: "centered",
    },
    tags: ["autodocs"],
} satisfies Meta<typeof Select>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Label: Story = {
    args: {
        name: "nums",
        label: "桁数",
        options: [{ key: "digit1", value: "1", label: "1桁" }, { key: "digit2", value: "2", label: "2桁" }, { key: "digit3", value: "3", label: "3桁" }],
        defaultValue: "digit1",
        onSelectChange: (value: string) => {console.log(`select is ${value}`)}
    },
};
export const NotLabel: Story = {
    args: {
        name: "nums",
        options: [{ key: "digit1", value: "1", label: "1桁" }, { key: "digit2", value: "2", label: "2桁" }, { key: "digit3", value: "3", label: "3桁" }],
        defaultValue: "digit1",
        onSelectChange: (value: string) => {console.log(`select is ${value}`)}
    },
};

ラベルあり、ラベル無しのstoryを用意しました。

テキストボックス

Input.tsx

import React, {useState} from "react";

export interface InputProps {
    name: string;
    label?: string;
    defaultValue?: string;
    placeholder?: string;
    onInputChange?: (value: string) => void;
}

export const Input = ({ name, label = "", defaultValue="", placeholder = "", onInputChange, ...props }: InputProps) => {
    const [inputed, setInputed] = useState(defaultValue);
    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        if (onInputChange) {
            onInputChange(event.target.value);
        }
        setInputed(event.target.value);
    };
    return (
        <div className="grid gap-y-1">
            <label htmlFor={name} className="text-sm text-mainDarkGray">
                {label}
            </label>
            <input type="text" name={name} value={inputed} placeholder={placeholder} onChange={handleChange} className={["h-9","p-2", "rounded-md", "box-border", "border-solid", "border", "border-mainLightGray"].join(" ")} {...props} />
        </div>
    );
};

Selectと同様、ラベルを表示切替できるようにしました。
クラス設定もほぼ同じです。

Input.stories.ts

import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./Input";

const meta = {
    title: "Lottery/Input",
    component: Input,
    parameters: {
        layout: "centered",
    },
    tags: ["autodocs"],
} satisfies Meta<typeof Input>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Label: Story = {
    args: {
        name: "max",
        label: "最大値",
        placeholder: "抽選番号の最大値",
        onInputChange: (value: string) => {console.log(`Input is ${value}`)}
    },
};
export const NotLabel: Story = {
    args: {
        name: "max",
        placeholder: "抽選番号の最大値",
        onInputChange: (value: string) => {console.log(`Input is ${value}`)}
    },
};

こちらもラベルあり、ラベル無しのstoryを用意しました。

ボタン

Button.tsx

export interface ButtonProps {
    label: string;
    primary?: boolean;
    disabled?: boolean;
    size?: "small" | "medium" | "large";
    onClick?: () => void;
}

export const Button = ({label, primary = true, disabled= false, size= "medium", ...props}: ButtonProps) => {
    const mode = primary ? ["text-white", "bg-mainBlue"] : ["text-mainBlue", "bg-white"];
    const isDisabled = disabled ? "opacity-30" : "";
    const sizeList = {
        small: ["w-12", "py-1", "text-xs"],
        medium: ["w-24", "py-2", "text-base"],
        large: ["w-48", "py-4", "text-xl"],
    };
    return (
        <button type="button" className={[ "text-center","rounded-md", "box-border", "divide-solid", "border", "border-mainBlue", `${isDisabled}`, ...sizeList[size], ...mode].join(" ")} disabled={disabled} {...props}>
            {label}
        </button>
    );
};

create-next-appのサンプルにあったButtonコンポーネントを修正して使っています。(丁度良かった!)
サイズを大中小から選んで使います。
primaryカラーやdisabledの設定が可能です。
disabled時は透過率を30%になるようクラス設定しました。

  • opacity-30 → opacity: 30%

Button.stories.ts

import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta = {
    title: "Lottery/Button",
    component: Button,
    parameters: {
        layout: "centered",
    },
    tags: ["autodocs"],
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
    args: {
        label: "抽選",
        primary: true,
    },
};
export const PrimarySmall: Story = {
    args: {
        label: "抽選",
        primary: true,
        size: "small"
    },
};
export const PrimaryLarge: Story = {
    args: {
        label: "抽選",
        primary: true,
        size: "large"
    },
};
export const PrimaryDisabled: Story = {
    args: {
        label: "抽選",
        primary: true,
        disabled: true,
    },
};
export const Secondary: Story = {
    args: {
        label: "抽選済み",
        primary: false,
    },
};
export const SecondaryDisabled: Story = {
    args: {
        label: "抽選済み",
        primary: false,
        disabled: true,
    },
};

ボタンサイズ、primary、disabledを組み合わせて用意しました。
「Storybookで管理しているぞ!」という気持ちになりました。

抽選パネル

Panel.tsx

import { useState, useEffect, useCallback } from "react";
import { Button } from "../button/Button";

export interface PanelProps {
    url?: string;
    getNumber?: () => string | number | Promise<string | number>;
    drawTrigger: boolean;
    resetTrigger: number;
}
const defaultGetNumber = () => {
    return Math.floor(Math.random() * 10);
};
export const Panel = ({ getNumber = defaultGetNumber, drawTrigger, resetTrigger }: PanelProps) => {
    const [number, setNumber] = useState<number | string>("?");
    const [isFetched, setIsFetched] = useState(Number.isInteger(false));
    // 数字を抽選
    const drawNumber = useCallback(async () => {
        const randomNumber = await getNumber()
        setNumber(randomNumber);
        setIsFetched(true);
    }, [getNumber])
    const mode = isFetched ? ["text-white", "bg-mainBlue"] : ["text-mainBlue", "bg-white"];
    const label = isFetched ? "抽選済み" : "抽選";
    // 外部からの抽選処理
    useEffect(() => {
        if (drawTrigger && !isFetched) {
            drawNumber();
        }
    }, [drawTrigger, isFetched, drawNumber]);
    // 外部からのリセット処理
    useEffect(() => {
        setNumber("?");
        setIsFetched(false);
    }, [resetTrigger]);
    return (
        <div className={["w-fit", "grid", "gap-y-3", "px-3", " py-4", "rounded-lg", "box-border", "shadow-lg"].join(" ")}>
            <div className={["w-auto", "grid", "justify-items-center", "items-center", "aspect-square", "rounded-xl", "box-border", "divide-solid", "border-2", "border-mainBlue", "text-5xl", "font-bold", ...mode].join(" ")}>{number}</div>
            <Button label={label} primary={!isFetched} disabled={isFetched} onClick={drawNumber} />
        </div>
    );
};

一括抽選やリセットに対応できるよう、それぞれpropsにトリガーを用意しました。

パネルに描画する数字の取得方法はpropsで渡せるようにしました。
最終的にはaxiosを使ってAPIから数字を取得する予定です。

パネルを正方形にするため、下記のクラスを設定しています。

  • aspect-square → aspect-ratio: 1 / 1

Panel.stories.ts

import type { Meta, StoryObj } from "@storybook/react";
import { Panel } from "./Panel";

const meta = {
    title: "Lottery/Panel",
    component: Panel,
    parameters: {
        layout: "centered",
    },
    tags: ["autodocs"],
} satisfies Meta<typeof Panel>;

export default meta;
type Story = StoryObj<typeof meta>;

export const NotDrawed: Story = {
    args: {
        drawTrigger: false,
        resetTrigger: 0
    },
};
export const Drawed: Story = {
    args: {
        drawTrigger: true,
        resetTrigger: 0
    },
};

数字描画前と後の2種類を用意しました。
コンポーネントの作成完了!

おわりに

コンポーネント作成は以上です。
次回はAPIを呼び出せるように設定していきます。