NextJS11-TypeScriptにStorybook6.4を導入する。
作業ログとしてgithubを記述しておく。
cssmodule-sass-storybook-sample
create-next-appでNextJSを導入する。
yarn create next-app --typescript cssmodule-sass-storybook-sample
yarn create v1.22.10
依存関係
"dependencies": {
"next": "11.0.1",
"react": "17.0.2",
"react-dom": "17.0.2"
},
"devDependencies": {
"@types/react": "17.0.14",
"eslint": "7.30.0",
"eslint-config-next": "11.0.1",
"typescript": "4.3.5"
}
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
立ち上がることを確認
npx sb init --builder webpack5
以前builder webpack5を設定せずにNextJS11からStroybookを起動させようとしたらエラーだらけでひどいめにあった。
今回はうまくいくかどうか......。
yarn storybook
上記で起動すると下記エラーがでる。
Error: Cannot find module 'webpack/lib/util/makeSerializable.js'
このエラーはWebpack5を入れれば解決する。
Storybook build fails with "Cannot find module 'webpack/lib/util/makeSerializable.js" after upgrading storybook packages to 6.3.0
yarn storybook
yarn run v1.22.10
$ start-storybook -p 6006
info @storybook/react v6.3.4
~~~~~
info => Using implicit CSS loaders
info => Using default Webpack5 setup
Webpack5で動いていることが確認できる。
Sassを使っていないのにも関わらず、起動したStorybookで
Addon controls: Control of type color only supports string, received "undefined" instead
とエラーが出ているが気にしない。
ここまでの作業で一旦保存する。
Github
いよいよStorybookのSassおよびCSS moduleに取り組む。
StroybookはCSS-in-JS推しなのに対し、NextJSはCSS module推しなのが原因だと思われる。
CSS moduleにするにあたり、下記のファイルを整形しようとすると初手でTypeScriptエラーになる。
// storis/Button.stories.tsx
export default {
title: 'Example/Button',
component: Button,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof Button>;
エラー内容は下記のとおり、
TS4082: Default export of the module has or is using private name 'ButtonProps'.
だからexportすれば大丈夫。
// storis/Button.tsx
export interface ButtonProps
button.cssをButton.module.scssに名前変更する。
それに伴いclass名も全部変更する。
.storybook-button {}
.storybook-button--primary {}
.storybook-button--secondary {}
.storybook-button--small {}
.storybook-button--medium {}
.storybook-button--large {}
.storybookButton {}
.storybookButtonPrimary {}
.storybookButtonSecondary {}
.storybookButtonSmall {}
.storybookButtonMedium {}
.storybookButtonLarge {}
css moduleに関してはあまり詳しくないのでよくわからないが、色々やってこうやったら動いたので書いていく。
多分だけどこんな書き方ができる。
// Button.tsx
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
// Button.tsx
<button
type="button"
className={
clsx(
styles.storybookButton,
size === "small" && styles.storybookButtonSmall,
size === "medium" && styles.storybookButtonMedium,
size === "large" && styles.storybookButtonLarge,
primary ? styles.storybookButtonPrimary : styles.storybookButtonSecondary
)
}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
yarn storybook
を実行してもErrorが出て失敗する。
ModuleNotFoundError: Module not found: Error: Can't resolve 'stories/Button.module.scss' in '/Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/stories'
意訳:「stories/Button.module.scss」なんていうファイルは見つからない。
Parsed request is a module
using description file: /Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/package.json (relative path: ./stories)
意訳:ちょっと何言ってるかわからないですね。package.jsonにもありませんし......。
looking for modules in /Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/node_module /Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/node_modules/stories doesn't exist
意訳:node_modulesの中にも無いですよ。
// .storybook/main.js
module.exports = {
"stories": [
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials"
],
"core": {
"builder": "webpack5"
}
}
上記にCSS moduleの設定がないからである。(それ以前にTypeScriptの"baseUrl": "."
が関係していると思う。)
baseUrl対応
const path = require('path')
module.exports = {
"stories": [
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials"
],
"core": {
"builder": "webpack5"
},
webpackFinal: async (config) => {
config.resolve.modules = [
...(config.resolve.modules || []),
path.resolve(__dirname, "../"),
];
return config;
},
}
上記のように設定すると
意訳:loaderの設定が間違ってるんじゃない?と言われる。
ModuleParseError: Module parse failed: Unexpected token (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
ちなみに、この人のいうようにやってもうまく行かなかった。
なんかtsconfig-paths-webpack-plugin
がうまく動作してくれない。
詳しい人教えてください!
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
"stories": [
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials"
],
"core": {
"builder": "webpack5"
},
webpackFinal: async (config) => {
config.resolve.plugins.push(new TsconfigPathsPlugin({}));
return config;
},
}
loader対応
yarn add -D @storybook/preset-scss css-loader sass-loader
const path = require('path')
module.exports = {
"stories": [
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
{
name: "@storybook/preset-scss",
options: {
cssLoaderOptions: {
modules: true,
},
scssLoaderOptions: {
sourceMap: true,
},
},
},
],
"core": {
"builder": "webpack5"
},
webpackFinal: async (config) => {
config.resolve.modules = [
...(config.resolve.modules || []),
path.resolve(__dirname, "../"),
];
return config;
},
}
yarn storybook
で確認する。
結局使わなかったtsconfig-paths-webpack-plugin
などは削除しておく。
Github
ここまで調べるのにほぼ丸一日かかった。
時間かけすぎかもしれないが、webpack周りなどの動作や、仕組みがわかってとても勉強になった。
なにかあれば教えてください。
そもそも最初に困ったのは下記エラー
Error: Module build failed (from ./node_modules/sass-loader/dist/cjs.js): TypeError: this.getOptions is not a function
このときwebpackのバージョンがVersion: webpack 4.46.0で起動していた。
upgradeを試すupgradeを試してからいろいろな問題にぶちあたった。
・sass-loaderがversion 11からwebpack5からのサポートになった問題
・TypeScriptのbaseUrl問題
・Loader周りの問題
・scssでcss moduleってどうやって使うんだろう問題
・clsxって何?問題
など。
感想としてはStorybookもNextJSもwebpack周りがうまいこと隠れているからこそ、その配下の設定をいじろうとか、バージョンの互換性からずれたことをやろうとすると、結構大変なんだなぁということ。
バージョン合わせは飽きたので、明日はコードを書きたい。
storybookではnext/imageがうまく読み取れていないようだ。
<img alt="dashboard" src="/_next/image?url=%2Fuser%2Favatar.jpeg&w=96&q=75" decoding="async" srcset="/_next/image?url=%2Fuser%2Favatar.jpeg&w=48&q=75 1x, /_next/image?url=%2Fuser%2Favatar.jpeg&w=96&q=75 2x" style="position: absolute; inset: 0px; box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;">
storybook上で画像を読み込みたい場合には下記のようにpreview.jsに設定を読み込ませる。
// .storybook/preview.js
import * as nextImage from 'next/image'
Object.defineProperty(nextImage, 'default', {
configurable: true,
value: (props) => <img {...props} />,
})
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}
// package.json
"storybook": "start-storybook -s ./public -p 6006",
これでyarn storybookで呼び出せる。
<img src="/user/avatar.jpeg" alt="dashboard" width="40" height="40">
参考
// package.json
"storybook": "start-storybook -s ./public -p 6006",
info => Loading presets
info => Serving static files from ././public at /
// package.json
"storybook": "start-storybook -s public -p 6006",
info => Loading presets
info => Serving static files from ./public at /
上二つ、どちらでも問題ないが、ログで表示されるパスが気持ちわるいので、自分は下でいこうと思う。
"storybook": "start-storybook -s public -p 6006",
Test周り storiesの作成
ComponentMeta
For the common case where a component's stories are simple components that receives args as props
コンポーネントのストーリーが、引数をプロップとして受け取るシンプルなコンポーネントである場合、一般的にはexport default { ... } as ComponentMeta<typeof Button>;
ComponentStory
For the common case where a story is a simple component that receives args as props
ストーリーが、引数をプロップとして受け取るシンプルなコンポーネントであるという一般的なケースでは、const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />
サンプル
// Button.stories.tsx
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import { Button } from './Button'
export default {
title: 'Example/Button',
component: Button,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof Button>
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />
export const Primary = Template.bind({})
Primary.args = {
primary: true,
label: 'Button',
}
export const Secondary = Template.bind({})
Secondary.args = {
label: 'Button',
}
export const Large = Template.bind({})
Large.args = {
size: 'large',
label: 'Button',
}
export const Small = Template.bind({})
Small.args = {
size: 'small',
label: 'Button',
}
// Button.module.scss
.storybookButton {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybookButtonPrimary {
color: white;
background-color: #1ea7fd;
}
.storybookButtonSecondary {
color: #444;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybookButtonSmall {
font-size: 12px;
padding: 10px 16px;
}
.storybookButtonMedium {
font-size: 14px;
padding: 11px 20px;
}
.storybookButtonLarge {
font-size: 16px;
padding: 12px 24px;
}
// Button.tsx
import React from 'react';
import clsx from "clsx"
import styles from 'stories/Button.module.scss';
export interface ButtonProps {
/**
* Is this the principal call to action on the page?
*/
primary?: boolean;
/**
* What background color to use
*/
backgroundColor?: string;
/**
* How large should the button be?
*/
size?: 'small' | 'medium' | 'large';
/**
* Button contents
*/
label: string;
/**
* Optional click handler
*/
onClick?: () => void;
}
/**
* Primary UI component for user interaction
*/
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
return (
<button
type="button"
className={
clsx(
styles.storybookButton,
size === "small" && styles.storybookButtonSmall,
size === "medium" && styles.storybookButtonMedium,
size === "large" && styles.storybookButtonLarge,
primary ? styles.storybookButtonPrimary : styles.storybookButtonSecondary
)
}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};
引数なしの場合はどうするのかと探していたら公式のやり方でできた。
この型が厳密に何を示しているかは調査中(複数要素があり、確認しているところ)だが、storiesの書式にエラーがあると通知してくれるみたい。
// Original/Tab.tsx
import { Meta, Story } from '@storybook/react'
import Tab from 'components/dashboard/Tab'
export default {
title: 'components/dashboard/Tab',
component: Tab,
} as Meta
/**
* 引数なしの場合 Meta & Story
* @param {() => JSX.Element} args
* @return {JSX.Element}
* @constructor
*/
const Template: Story<typeof Tab> = (args) => <Tab {...args} />
export const Default = Template.bind({})
Default.args = {}
UIのテストはstorybookで良いのではないかと思う。
ちなみに自分は初期にchromaticと動機させちゃう。
共有する人はいないけどPRと連動させてその内容であっているかを確かめるのに便利。
前に練習で作ったやつ(Reactとstyled-components)
UIに追加されている機能テストはjestとreact-testing-libraryとかで対応する感じなのかな?
E2Eテストとしてはcypressが良いのかな?
今作っているのはZennを参考にしているため、外観がとても似ている。
いろいろ考えてGithubでprivate設定にしてしまった。
将来的には自分なりの設定にしてpublicにしようかと考えている。
また気づきがあればスクラップをopenにしようと思う。
そういえばだけど書いていて思ったのは、storiesの作成は基本ベースだけであれば自動化できるんじゃないかな?と思った。
- 作成したい元になるcomponentファイルを指定する。
- 1で指定したファイルを解析し、propsが必要か不要か判定。
- propsが必要ならComponentStory, ComponentMeta / propsが不要ならStory, Meta
- 生成データの基本は下記の型を使う。
- 生成するパスはrootにjsonファイルかなんか作るように指定して、そこからの相対パスで設定できるとかすれば汎用性は高そう。
export default {
title: 'Example/Header',
component: Header,
} as ComponentMeta<typeof Header>;
const Template: ComponentStory<typeof Header> = (args) => <Header {...args} />;
export const Default = Template.bind({});
Default.args = {};
nodeからjsx/tsxを指定して解析する方法がわからない(調べていない)ので今回はできないけど、WebStormのライブテンプレートを使っても幾分か楽ができそうなので、ひとまずそれで試してみよう。
下記のライブテンプレートを使ったら楽だった。
// LiveTemplate
import { Meta, Story } from '@storybook/react'
export default {
title: ,
component: $component$,
} as Meta
const Template: Story<typeof $component$> = (args) => <$component$ {...args} />
export const $storyType$ = Template.bind({})
$storyType$.args = {}
// LiveTemplate
import { ComponentStory, ComponentMeta } from '@storybook/react'
export default {
title: ,
component: $component$,
} as ComponentMeta<typeof $component$>
const Template: ComponentStory<typeof $component$> = (args) => <$component$ {...args} />
export const $storyType$ = Template.bind({})
$storyType$.args = {}
使い方:
-
と記載している箇所にはcomponentの名前を記載する。component - component名を記載してタブを押下すれば、exportの設定が対象componentに入っている際にWebStormの機能で自動補完が走る。
- storyTypeはお好みでつける。
- titleには自動補完で記載されたpathの設定をそのまま書く。
import { ComponentStory, ComponentMeta } from '@storybook/react'
import SectionTitle from 'components/layout/SectionTitle'
export default {
title: 'components/layout/SectionTitle',
component: SectionTitle,
} as ComponentMeta<typeof SectionTitle>
const Template: ComponentStory<typeof SectionTitle> = (args) => <SectionTitle {...args} />
export const Default = Template.bind({})
Default.args = {
title: 'Articles',
src: 'https://twemoji.maxcdn.com/v/13.1.0/svg/1f4dd.svg',
}
この程度であればとても楽。
global.scssの読み込ませ方
基本NextJSではpageフォルダの_app.tsxにglobal環境に適用するCSSファイルを設置する。
storybookは読み込まれ方がNextJSのものと根本から異なるため、storybookにも同じようにimportしてやる必要がある。
// ./page/_app.tsx
import Head from 'next/head'
import type { AppProps } from 'next/app'
import 'styles/globals.scss'
import Layout from 'components/layout/layout'
function MyApp({ Component, pageProps }: AppProps) {
return (
<Layout>
<Head>
<title>PlayWell</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="theme-color" content="#ffffff" />
<meta name={'description'} content={'This is PlayWell Blog'} />
<meta name={'viewport'} content={'initial-scale=1.0, width=device-width'} />
</Head>
<Component {...pageProps} />
</Layout>
)
}
export default MyApp
下記のように設置することができる。
// ./storybook/preview.js
import * as nextImage from 'next/image'
import 'styles/globals.scss'
Object.defineProperty(nextImage, 'default', {
configurable: true,
value: (props) => <img {...props} />,
})
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}
参考
今日作業をしていて気づいたのは、NextJSでのnext/imageでsrcに画像を設定すると、chromaticでは読み取られない。
どうやら、storybook -s public
のような設定がないようで、読み取れなかった。
普通にReactで作成するならちゃんとアップロードできるんだけど、うまくいかない。
これについては調べてみたけどよくわからないので諦めることにする。
ちなみに、svgであればコンポーネント化して読み取りが可能。