presentational はデザインの部分を担当する。一方で container がビジネスロジックを担当する。見た目と挙動で責務を分ける形で、可読性と保守性を分けている。
デザインを階層的に定義することで一貫性を保ち管理しやすくしている。
styled.要素名スタイル``の書き方でやる。
import { NextPage } from "next";
import styled from "styled-components";
type ButtonProps = {
color: string,
backgroundColor: string
};
//propsも定義することができる
const Button =
styled.button <
ButtonProps >
`
color: ${props => props.color};
background: ${props => props.backgroundColor};
border: 2px solid ${props => props.color};
font-size: 2em;
margin: 1em;
padding: 0.25em 1em;
border-radius: 8px;
cursor: pointer;
`;
const Page: NextPage = () => {
return (
<div>
<Button backgroundColor="transparent" color="#FF0000">
Hello
</Button>
<Button backgroundColor="1E90FF" color="white">
Hello
</Button>
</div>
);
};
export default Page;
共通のものは mixin として定義し、css 内部に入れることで共通のものを適用できる。
const fontSize = css`
font-size: 1em;
`;
const Button =
styled.button <
ButtonProps >
`
color: ${props => props.color};
background: ${props => props.backgroundColor};
border: 2px solid ${props => props.color};
${fontSize}
margin: 1em;
padding: 0.25em 1em;
border-radius: 8px;
cursor: pointer;
`;
スタイルを継承するときに有用。
const Text = styled.p`
${fontSize};
font-weight:bold
`
//styled(Text)でTextのstyleを継承する
const TextBorder = styled(Text)`
padding:1px
`
//pタグのなかにHelloが入り、TextBorderとTextのstyleが適用される。
<TextBorder>
Hello
</TextBorder>
//asを利用することでaタグとして機能することができるようになる。
<TextBorder as='a' href="/">
Hello
</TextBorder>
theme を設定しておきそれを props で受け取ることで theme の内容を参照できるようになる。
//theme.ts
export const theme = {
space: ["0px", "4px", "8px", "16px", "24px", "32px"],
colors: {
white: "#ffffff",
black: "#000000",
red: "#ff0000"
},
fontSizes: ["12px"]
};
//pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "styled-components";
import { theme } from "./theme";
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider theme={theme}>
<Component {...pageProps} />
</ThemeProvider>
);
}
export default MyApp;
//pages/sampleTheme.tsx
import { NextPage } from "next";
import styled from "styled-components";
const Text = styled.span`
color: ${props => props.theme.colors.red};
`;
const Page: NextPage = () => {
return (
<div>
<Text>Themeから参照したもの</Text>
</div>
);
};
export default Page;
個々のコンポーネントを確認することができるツール
npx sb@latest init
npm run storybook
//components/StyledButton/index.tsx
import styled, { css } from "styled-components";
const variants = {
primary: {
color: "#ffffff",
backgroundColor: "#1D3461",
border: "none",
},
success: {
color: "#ffffff",
backgroundColor: "green",
border: "none",
},
transparent: {
color: "#111111",
backgroundColor: "transparent",
border: "1px solid black",
},
} as const;
type StyledButtonProps = {
//keyof typeof→ typeofが変数を型に変換、keyでconst化
variant: keyof typeof variants;
};
//{variant} → props.variantで参照可能なため{}で展開したものを入れている
export const StyledButton = styled.button<StyledButtonProps>`
${({ variant }) => {
const style = variants[variant];
return css``;
}}
border-radius:12px;
font-size: 14px;
height: 38px;
line-height: 22px;
letter-spacing: 0;
cursor: pointer;
&:focus {
outline: none;
}
`;
//stories/StyledButton.stories.tsx
import { ComponentMeta } from "@storybook/react";
import { StyledButton } from "../components/StyledButton";
export default {
title: "StyledButton",
component: StyledButton,
//クリックの宣言をすることができるようになる
argTypes: { onClick: { action: "clicked" } },
} as ComponentMeta<typeof StyledButton>;
export const Primary = (props: any) => {
return (
<StyledButton {...props} variant="primary">
Primary
</StyledButton>
);
};
export const Success = (props: any) => {
return (
<StyledButton {...props} variant="success">
Success
</StyledButton>
);
};
export const Transparent = (props: any) => {
return (
<StyledButton {...props} variant="transparent">
Transparent
</StyledButton>
);
};
//actionで任意のclickイベントが動くかを確認できる
const incrementAction = action("increment");
export const Primary = (props: any) => {
const [count, setCount] = useState(0);
const onClick = (e: React.MouseEvent) => {
incrementAction(e, count);
setCount(c => c + 1);
};
return (
<StyledButton {...props} variant="primary" onClick={onClick}>
count:{count}
</StyledButton>
);
};
定義したコンポーネントに渡す props を設定することができる
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { StyledButton } from "../components/StyledButton";
import { action } from "@storybook/addon-actions";
import React, { useState } from "react";
export default {
title: "StyledButton",
component: StyledButton,
argTypes: {
//propsに渡すものを定義する
variant: {
control: { type: "radio" },
options: ["primary", "success", "transparent"],
},
children: {
control: { type: "text" },
},
},
} as ComponentMeta<typeof StyledButton>;
const Template: ComponentStory<typeof StyledButton> = (args) => (
<StyledButton {...args} />
);
export const TemplateTest = Template.bind({});
TemplateTest.args = {
variant: "primary",
children: "primary",
};
npm install --save-dev @storybook/addon-essentials
を入れて設定。ここでは document を記載する方法だけ記載する。
//StyledButton.stories.tsx
import MDXDocument from "./StyledButton.mdx";
export default {
title: "StyledButton",
component: StyledButton,
argTypes: {
//propsに渡すものを定義する
variant: {
control: { type: "radio" },
options: ["primary", "success", "transparent"],
},
children: {
control: { type: "text" },
},
},
//parameter内にdocumnentを記載したmarkdownファイルを入れる
parameters: {
docs: {
page: MDXDocument,
},
},
} as ComponentMeta<typeof StyledButton>;
//StyledButton.mdx
import { StyledButton } from "../components/StyledButton";
## StyledButton
StyledButton は atoms です
- primary
- success
- transparent
を受け取ります。
### primary
tsx
<StyledButton variant="primary">Primary</StyledButton>
React Testing laibrary が推奨されている。コンポーネントを実際に描画してみてその結果の DOM にサクセスをして正しく描画されているかをテストする。
npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
//jest.config.js
const nextJest = require("next/jest");
const createJestConfig = nextJest({
// next.config.jsとテスト環境用の.envファイルが配置されたディレクトリをセット。基本は"./"で良い。
dir: "./"
});
// Jestのカスタム設定を設置する場所。従来のプロパティはここで定義。
const customJestConfig = {
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testEnvironment: "jsdom"
};
// createJestConfigを定義することによって、本ファイルで定義された設定がNext.jsの設定に反映されます
module.exports = createJestConfig(customJestConfig);
//Input.tsx
import { useState } from "react";
type InputProps = JSX.IntrinsicElements["input"] & {
label: string;
};
export const Input = (props: InputProps) => {
const { label, ...rest } = props;
const [text, setText] = useState("");
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const resetInputField = () => {
setText("");
};
return (
<div>
<label htmlFor={props.id}>{label}</label>
<input {...rest} type="text" value={text} onChange={onInputChange} />
<button onClick={resetInputField}>Reset</button>
</div>
);
};
import { render, screen, RenderResult } from "@testing-library/react";
import { Input } from "./index";
describe("Input", () => {
let renderResult: RenderResult;
beforeEach(() => {
//Inputを描画する設定
renderResult = render(<Input id="username" label="Username" />);
});
afterEach(() => {
//各々のtest前に解放
renderResult.unmount();
});
it("正常_描かれたときにから文字になっているか", () => {
//getByLabelTextを使うことで描画されているDOMから指定した名前のラベルに対応するInputを取得する
//labelから取得しているため型はInputElementになる(ならければならばい)
const inputNode = screen.getByLabelText("Username") as HTMLInputElement;
expect(inputNode).toHaveValue("");
});
});
//labelタグが存在しない場合、<input aria-label={label} />と書くことで参照できるようになる。
値を入れることができているかの test。react の中でもfireEventを利用することで実装することができる。
it("正常_入力した内容が表示される", () => {
const inputText = "test input text";
const inputNode = screen.getByLabelText("Username") as HTMLInputElement;
//changeイベントを発火させる
fireEvent.change(inputNode, { target: { value: inputText } });
expect(inputNode).toHaveValue(inputText);
});
reset のボタンが押せているか
it("正常_ボタンを押すことでテキスト内容がresetされる", () => {
const inputText = "test input text";
const inputNode = screen.getByLabelText("Username") as HTMLInputElement;
//changeイベントを発火させる
fireEvent.change(inputNode, { target: { value: inputText } });
//ボタンを取得
//ボタンタグはroleを取得する。defaultでbuttonというroleが入るので取得。第二引数でボタンで表示しているテキストを指定
const buttonNode = screen.getByRole("button", {
name: "Reset",
}) as HTMLButtonElement;
fireEvent.click(buttonNode);
expect(inputNode).toHaveValue("");
});
入力したテキストが入力中であるかを表示するコンポーネントをテストする
import React, { useCallback, useRef, useState } from "react";
type DelayButtonProps = {
onChange: React.ChangeEventHandler<HTMLInputElement>;
};
export const DelayInput = (props: DelayButtonProps) => {
const { onChange } = props;
const [isTyping, setIsTyping] = useState(false);
const [inputValue, setInputValue] = useState("");
const [viewValue, setViewValue] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const text = isTyping ? "入力中..." : `入力したテキスト:${viewValue}`;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setIsTyping(true);
setInputValue(e.target.value);
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
setIsTyping(false);
setViewValue(e.target.value);
onChange(e);
}, 1000);
},
[onChange]
);
return (
<div>
<input
data-testid="input-text"
value={inputValue}
onChange={handleChange}
/>
<span data-testid="display-text">{text}</span>
</div>
);
};
//test
import { fireEvent } from "@storybook/testing-library";
import { render, screen, RenderResult } from "@testing-library/react";
import { act } from "react-dom/test-utils";
import { DelayInput } from "./index";
describe("DelayInput", () => {
let renderResult: RenderResult;
let handleChange: jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
//mock
handleChange = jest.fn();
//Inputを描画する設定
renderResult = render(<DelayInput onChange={handleChange} />);
});
afterEach(() => {
//各々のtest前に解放
renderResult.unmount();
jest.useFakeTimers();
});
it("正常_テキストが表示されているか", () => {
const spanNode = screen.getByTestId("display-text") as HTMLSpanElement;
expect(spanNode).toHaveTextContent("入力したテキスト:");
});
it("正常_入力中と表示されているか", () => {
const inputText = "test input text";
const inputNode = screen.getByTestId("input-text") as HTMLInputElement;
fireEvent.change(inputNode, { target: { value: inputText } });
const spanNode = screen.getByTestId("display-text") as HTMLSpanElement;
expect(spanNode).toHaveTextContent("入力中...");
});
it("正常_入力して1秒後にテキストが表示されるか", async () => {
const inputText = "test input text";
const inputNode = screen.getByTestId("input-text") as HTMLInputElement;
fireEvent.change(inputNode, { target: { value: inputText } });
//actを実行することで内部でtimeoutを待たずに実行されることを保証する
await act(() => {
jest.runAllTimers();
});
const spanNode = screen.getByTestId("display-text") as HTMLSpanElement;
expect(spanNode).toHaveTextContent(`入力したテキスト:${inputText}`);
expect(handleChange).toHaveBeenCalled();
});
});