StoryBookとは?
- 主にUIコンポーネントを独立した環境で開発、テスト、文書化するために使用される、オープンソースのツール
StoryBookを起動する
$ npm run storybook
StoryBookの使い方
タスクUIコンポーネントを作成する
①components/Task.jsxを作成する
components/Task.jsx
export default function Task({ task: { id, title, state } }) {
return (
<div className='list-item'>
<label htmlFor='title' className='title'>
<input
type='text'
value={title}
readOnly={true}
name='title'
placeholder='Input title'
/>
</label>
</div>
);
}
タスクUI用のStoryBookを準備する
①components/Task.stories.jsxを作成する
components/Task.stories.jsx
import Task from './Task';
export default {
Components: Task,
title: 'Task',
};
export const Default = {
args: {
task: {
id: '1',
title: 'test task',
state: 'TASK_INBOX',
},
},
};
②preview.tsにimportを追加する
preview.ts
import '../src/index.css';
タスクUIコンポーネントを修正する
components/Task.jsx
export default function Task({ task: { id, title, state } }) {
return (
<div className={`list-item ${state}`}>
<label htmlFor='checked' className='checkbox'>
<input type='checkbox' name='checked' id={`archiveTask-${id}`} />
<span className='checkbox-custom'></span>
</label>
<label htmlFor='title' className='title'>
<input
type='text'
value={title}
readOnly={true}
name='title'
placeholder='Input title'
/>
</label>
{state !== 'TASK_ARCHIVED' && (
<button className='pin-button' id={`pinTask-${id}`}>
<span className='icon-star'></span>
</button>
)}
</div>
);
}
タスク状態によってUIが変わるカタログを追加
components/Task.stories.jsx
import Task from './Task';
export default {
Components: Task,
title: 'Task',
};
export const Default = {
args: {
task: {
id: '1',
title: 'test task',
state: 'TASK_INBOX',
},
},
};
export const Pinned = {
args: {
task: {
// id: '1',
// title: 'test task',
// state: 'TASK_PINNED',
//スプレッド構文
...Default.args.task,
state: 'TASK_PINNED',
},
},
};
export const Archived = {
args: {
task: {
// id: '1',
// title: 'test task',
// state: 'TASK_PINNED',
//スプレッド構文
...Default.args.task,
state: 'TASK_ARCHIVED',
},
},
};
コンポーネントのpropsで渡す値に型を定義する
components/Task.jsx
import PropType from 'prop-types';
export default function Task({ task: { id, title, state } }) {
return (
<div className={`list-item ${state}`}>
<label htmlFor='checked' className='checkbox'>
<input type='checkbox' name='checked' id={`archiveTask-${id}`} />
<span className='checkbox-custom'></span>
</label>
<label htmlFor='title' className='title'>
<input
type='text'
value={title}
readOnly={true}
name='title'
placeholder='Input title'
/>
</label>
{state !== 'TASK_ARCHIVED' && (
<button className='pin-button' id={`pinTask-${id}`}>
<span className='icon-star'></span>
</button>
)}
</div>
);
}
Task.propTypes = {
task: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
state: PropTypes.string.isRequired,
}),
};
アドオンを追加してアクセシビリティを実行する
storybook/main.js
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: ['../public'],
addons: [
'@storybook/addon-docs',
'@storybook/addon-vitest',
'@chromatic-com/storybook',
//追加
'@storybook/addon-ally',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;
components/Task.jsx
import PropType from 'prop-types';
export default function Task({ task: { id, title, state } }) {
return (
<div className={`list-item ${state}`}>
<label
htmlFor='checked'
className='checkbox'
aria-label={`archiveTask-${id}`}
>
<input type='checkbox' name='checked' id={`archiveTask-${id}`} />
<span className='checkbox-custom'></span>
</label>
<label htmlFor='title' className='title' aria-label={title}>
<input
type='text'
value={title}
readOnly={true}
name='title'
placeholder='Input title'
/>
</label>
{state !== 'TASK_ARCHIVED' && (
<button
className='pin-button'
id={`pinTask-${id}`}
aria-label={`pinTask-${id}`}
>
<span className='icon-star'></span>
</button>
)}
</div>
);
}
Task.propTypes = {
task: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
state: PropTypes.string.isRequired,
}),
};
複合コンポーネントのタスクリストを作成する
①components/TaskList.jsxを作成する
components/TaskList.jsx
export default function TaskList({ Loading, tasks }) {
if (Loading) {
return <div className='list-item'>Loading...</div>;
}
if (tasks.length === 0) {
return <div className='list-item'>Empty...</div>;
}
return (
<div className='list-item'>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</div>
);
}
タスクリストUI用のStoryBookを追加する
①components/TaskList.stories.jsxを作成する
components/TaskList.stories.jsx
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
export default {
Components: TaskList,
title: 'TaskList',
decorators: [
(Story) => (
<div style={{ padding: '3rem' }}>
<Story />
</div>
),
],
};
export const Default = {
args: {
tasks: [
// {
// id: '1',
// title: 'Task 1',
// state: 'TASK_INBOX',
// },
// {
// id: '2',
// title: 'Task 2',
// state: 'TASK_INBOX',
// },
// {
// id: '3',
// title: 'Task 3',
// state: 'TASK_INBOX',
// },
//スプレッド構文
{
...TaskStories.Default.args.task,
id: '1',
title: 'Task 1',
},
{
...TaskStories.Default.args.task,
id: '2',
title: 'Task 2',
},
{
...TaskStories.Default.args.task,
id: '3',
title: 'Task 3',
},
{
...TaskStories.Default.args.task,
id: '4',
title: 'Task 4',
},
{
...TaskStories.Default.args.task,
id: '5',
title: 'Task 5',
},
{
...TaskStories.Default.args.task,
id: '6',
title: 'Task 6',
},
],
},
};
export const withPinnedTasks = {
args: {
tasks: [
...Default.args.tasks.slice(0, 5),
{
id: '6',
title: 'Task 6(pinned)',
state: 'TASK_PINNED',
},
],
},
};
ローディング・空状態のStoryBook・UIコンポーネントを作成する
components/TaskList.stories.jsx
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
export default {
Components: TaskList,
title: 'TaskList',
decorators: [
(Story) => (
<div style={{ padding: '3rem' }}>
<Story />
</div>
),
],
};
export const Default = {
args: {
tasks: [
// {
// id: '1',
// title: 'Task 1',
// state: 'TASK_INBOX',
// },
// {
// id: '2',
// title: 'Task 2',
// state: 'TASK_INBOX',
// },
// {
// id: '3',
// title: 'Task 3',
// state: 'TASK_INBOX',
// },
//スプレッド構文
{
...TaskStories.Default.args.task,
id: '1',
title: 'Task 1',
},
{
...TaskStories.Default.args.task,
id: '2',
title: 'Task 2',
},
{
...TaskStories.Default.args.task,
id: '3',
title: 'Task 3',
},
{
...TaskStories.Default.args.task,
id: '4',
title: 'Task 4',
},
{
...TaskStories.Default.args.task,
id: '5',
title: 'Task 5',
},
{
...TaskStories.Default.args.task,
id: '6',
title: 'Task 6',
},
],
},
};
export const withPinnedTasks = {
args: {
tasks: [
...Default.args.tasks.slice(0, 5),
{
id: '6',
title: 'Task 6(pinned)',
state: 'TASK_PINNED',
},
],
},
};
export const Loading = {
args: {
tasks: [],
loading: true,
},
};
export const Empty = {
args: {
...Loading.args,
loading: false,
},
};
components/TaskList.jsx
import Task from './Task';
export default function TaskList({ Loading, tasks }) {
const loadingRow = (
<div className='loading-item'>
<span className='glow-checkbox'></span>
<span className='glow-text'>
<span>Loading</span>
<span>cool</span>
<span>state</span>
</span>
</div>
);
if (Loading) {
return (
<div className='list-items'>
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className='list-items'>
<div className='wrapper-message'>
<span className='icon-check' />
<p className='title-message'>You have no tasks</p>
<p className='subtitle-message'>Sit back and relax</p>
</div>
</div>
);
}
return (
<div className='list-item'>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</div>
);
}
タスクリストで使用するデータの型を定義する
components/TaskList.jsx
import Task from './Task';
import PropType from 'prop-types';
export default function TaskList({ Loading, tasks }) {
const loadingRow = (
<div className='loading-item'>
<span className='glow-checkbox'></span>
<span className='glow-text'>
<span>Loading</span>
<span>cool</span>
<span>state</span>
</span>
</div>
);
if (Loading) {
return (
<div className='list-items'>
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className='list-items'>
<div className='wrapper-message'>
<span className='icon-check' />
<p className='title-message'>You have no tasks</p>
<p className='subtitle-message'>Sit back and relax</p>
</div>
</div>
);
}
return (
<div className='list-item'>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</div>
);
}
TaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.PropTypes.task).isRequired,
};
TaskList.defaultProps = {
loading: false,
};
データをUIコンポーネントに接続する
Reduxとは?
- 『アクション』と呼ばれるイベントを使用してアプリケーションの状態管理及び更新するためのパターン及びライブラリ
$ npm install @reduxjs/toolkit
$ npm install react-redux
①src/lib/store.jsを作成する
src/lib/store.js
const { createSlice } = require('@reduxjs/toolkit');
const defaultTasks = [
{ id: '1', title: 'something1', state: 'TASK_INBOX' },
{ id: '2', title: 'something2', state: 'TASK_INBOX' },
{ id: '3', title: 'something3', state: 'TASK_INBOX' },
{ id: '4', title: 'something4', state: 'TASK_INBOX' },
];
const TaskBoxData = {
tasks: defaultTasks,
status: 'idle',
error: null,
};
const TaskSlice = createSlice({
name: 'taskbox',
initialState: TaskBoxData,
reducers: {
updateTaskState: (state, action) => {
const { id, newTaskState } = action.payload;
const tasks = state.tasks.findIndex((task) => task.id === id);
if (tasks >= 0) {
state.tasks[tasks].state = newTaskState;
}
},
},
});
Storeを作ってアプリ全体でデータ共有ができる準備する
src/lib/store.js
const { createSlice, configureStore } = require('@reduxjs/toolkit');
const defaultTasks = [
{ id: '1', title: 'something 1', state: 'TASK_INBOX' },
{ id: '2', title: 'something 2', state: 'TASK_INBOX' },
{ id: '3', title: 'something 3', state: 'TASK_INBOX' },
{ id: '4', title: 'something 4', state: 'TASK_INBOX' },
];
const TaskBoxData = {
tasks: defaultTasks,
status: 'idle',
error: null,
};
const TaskSlice = createSlice({
name: 'taskbox',
initialState: TaskBoxData,
reducers: {
updateTaskState: (state, action) => {
const { id, newTaskState } = action.payload;
const tasks = state.tasks.findIndex((task) => task.id === id);
if (tasks >= 0) {
state.tasks[tasks].state = newTaskState;
}
},
},
});
export const { updateTaskState } = TaskSlice.actions;
const store = configureStore({
reducer: {
taskbox: TaskSlice.reducer,
},
});
export default store;
タスクリストのソート処理を行う
TaskList.js
import { useSelector } from 'react-redux';
import Task from './Task';
import PropType from 'prop-types';
export default function TaskList({ Loading, tasks }) {
const tasks = useSelector((state) => {
const tasksInOrder = [
...state.taskbox.tasks.filter((task) => task.id === 'TASK_PINNED'),
...state.taskbox.tasks.filter((task) => task.id !== 'TASK_PINNED'),
];
const filteredTasks = tasksInOrder.filter(
(task) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
);
return filteredTasks;
});
const loadingRow = (
<div className='loading-item'>
<span className='glow-checkbox'></span>
<span className='glow-text'>
<span>Loading</span>
<span>cool</span>
<span>state</span>
</span>
</div>
);
if (Loading) {
return (
<div className='list-items'>
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className='list-items'>
<div className='wrapper-message'>
<span className='icon-check' />
<p className='title-message'>You have no tasks</p>
<p className='subtitle-message'>Sit back and relax</p>
</div>
</div>
);
}
return (
<div className='list-item'>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</div>
);
}
TaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.PropTypes.task).isRequired,
};
TaskList.defaultProps = {
loading: false,
};