はじめに
前回まで
Next.js+Tailwind CSS+Storybookの環境構築を完了させました。
今回は各コンポーネントを用意していこうと思います。
- 環境構築
- Storybookで管理しながらコンポーネントを作成
- APIの呼び出し
- コンポーネントを組み合わせてレイアウト構築
完成図
今回は下記のコンポーネントを作ることにしました。
テキストボックス、ボタン、抽選パネルを使い回すことになりそうです。
- ヘッダー
- セレクトボックス
- テキストボックス
- ボタン
- 抽選パネル
コンポーネントを作成する
今回はTailwind CSSでデザイン設定しつつ、Storybookで確認&管理してみます。
カスタムクラス、画像
カスタムクラスとしてmainBlue
,mainLightGray
,mainDarkGray
を用意しました。
共通カラーとして使います。
tailwind.css config
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
1 2 3 4 5 6 7 8 9 10 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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を呼び出せるように設定していきます。