2023/12/17に公開
2023/12/29
本記事は、Next.jsでTrello風のTodoタスク管理アプリを作ってみた | 第1回に続く後編です。
前記事ではNext.jsのプロジェクトファイル作成まで説明しました。こちらでは各種ライブラリを使用しながら実際にコードを書いていきましょう。
Trello風のTodoタスク管理アプリの最終的なイメージは、前記事で紹介していますので、今一度ご確認ください。
まずは、Next.jsアプリの全体像を掴むために、ディレクトリ構成を説明します。
.
└── app
├── Component
│ ├── Container.tsx
│ ├── Header.tsx
│ ├── SortableContainer.tsx
│ └── SortableItem.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── page.module.css
└── page.tsx
以前のNext.jsではプロジェクト開始時にpages
というディレクトリが作成されていましたが、最新のNext.js 13からはapp
と呼ばれるディレクトリが作成されるようになりました。appフォルダ配下にアプリを構成するコンポーネントやレイアウトファイルなどがあります。
components
には、その名の通りアプリケーション全体で使うコンポーネントを入れます。今回はアプリケーションの規模が小さいため、これ以上ディレクトリを分けていませんが、大規模なアプリケーションになるとさらにコンポーネントの特性によってフォルダを分けることもあります。componentsフォルダ配下には、アプリで使用するヘッダーやTodoアイテムなどのコンポーネントがあるのが分かりますね。
layout.tsx
は、複数ページで使用する共通項目(ヘッダー、フッター、ナビゲーションなど)をレイアウトとして設定するファイルです。レイアウトに設定した内容はappフォルダ以下の全てのページに適用されます。
page.tsx
はアプリ内の各ページに対応するファイルです。新規にページを追加する場合、appフォルダ以下に新たなディレクトリを作成し、その中でpage.tsxを作成します。画面の表示項目を修正する場合は、基本的にpage.tsxを触ることになります。
他にもファイルやディレクトリがありますが、最低限上記の項目を押さえておけば大丈夫です。
続いて、Todoタスク管理アプリを開発する際に必要なドラッグ&ドロップライブラリ「dnd kit」をインストールしましょう。インストール手順は公式ドキュメントにも載っています。
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
dnd kitを入れることで、Reactアプリケーションでドラッグ&ドロップの機能を簡単に実装することができます。Todoタスクアイテムの順序を変えたり、ステータスを変えたりする部分をイメージするとわかりやすいと思います。
知識のインプットやライブラリのインストールが完了したので、各ファイルの中身を見ていきましょう。
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Header from './Component/Header'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
// metaタグの情報
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<body className={inter.className}>
<Header />
{children}
</body>
</html>
)
}
RootLayoutはデフォルトでエクスポートされる関数コンポーネントです。アプリケーションの共通のレイアウトとして、ComponentフォルダからimportしたHeaderを定義しています。Header.tsx
の中身は以下の通りです。
const Header = () => {
return (
<nav className='flex items-center justify-between flex-wrap p-6 w-full'>
<div className='flex items-center flex-shrink-0 text-white mr-6'>
<svg
className='fill-current h-8 w-8 mr-2'
width='54'
height='54'
viewBox='0 0 54 54'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M13.5 22.1c1.8-7.2 6.3-10.8 13.5-10.8 10.8 0 12.15 8.1 17.55 9.45 3.6.9 6.75-.45 9.45-4.05-1.8 7.2-6.3 10.8-13.5 10.8-10.8 0-12.15-8.1-17.55-9.45-3.6-.9-6.75.45-9.45 4.05zM0 38.3c1.8-7.2 6.3-10.8 13.5-10.8 10.8 0 12.15 8.1 17.55 9.45 3.6.9 6.75-.45 9.45-4.05-1.8 7.2-6.3 10.8-13.5 10.8-10.8 0-12.15-8.1-17.55-9.45-3.6-.9-6.75.45-9.45 4.05z' />
</svg>
<span className='font-semibold text-xl tracking-tight'>My Kanban App</span>
</div>
<div className='block lg:hidden'>
<button className='flex items-center px-3 py-2 border rounded text-teal-200 border-teal-400 hover:text-white hover:border-white'>
<svg
className='fill-current h-3 w-3'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'
>
<title>Menu</title>
<path d='M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z' />
</svg>
</button>
</div>
<div className='w-full block flex-grow lg:flex lg:items-center lg:w-auto'>
<div className='text-sm lg:flex-grow'>
<a
href='https://www.tearn.jp/'
className='block mt-4 lg:inline-block lg:mt-0 text-teal-200 hover:text-white mr-4'
target={'_blank'}
>
Docs
</a>
<a
href='https://www.tearn.jp/'
className='block mt-4 lg:inline-block lg:mt-0 text-teal-200 hover:text-white mr-4'
target={'_blank'}
>
Examples
</a>
<a
href='https://www.tearn.jp/'
className='block mt-4 lg:inline-block lg:mt-0 text-teal-200 hover:text-white'
target={'_blank'}
>
Blog
</a>
</div>
<div>
<a
href='#'
className='inline-block text-sm px-4 py-2 leading-none border rounded text-white border-white hover:border-transparent hover:text-teal-500 hover:bg-white hover:text-black mt-4 lg:mt-0'
>
Download
</a>
</div>
</div>
</nav>
)
}
export default Header
CSSに関してはTailwind CSS(CSSフレームワーク)を使用しているため、className='○○○'
と書くだけでシンプルに、コード量少なく実装することができます。
import Container from './Component/Container'
import styles from './page.module.css'
export default function Home() {
return (
<main className={styles.main}>
<Container />
</main>
)
}
こちらはメインで表示する画面です。コードの詳細は、importしているContainer.tsx
に実装しているため、page.tsxはコード量が少なくなっています。Container.tsx
の実装は以下の通りです。
'use client'
import React, { useState } from 'react'
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
UniqueIdentifier,
DragStartEvent,
DragOverEvent,
DragEndEvent,
} from '@dnd-kit/core'
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import SortableContainer from './SortableContainer'
import Item from './Item'
const Contaienr = () => {
// Todoタスクとして管理するアイテムリスト
const [items, setItems] = useState<{
[key: string]: string[]
}>({
container1: ['英単語を覚える', '犬の散歩をする', '会話する'],
container2: ['日用品を買う', 'お菓子を食べる'],
container3: ['サッカーを観戦する', '服を買う', 'テレビを観る', '夕飯を作る'],
container4: [],
})
//アイテムリストのリソースid
const [activeId, setActiveId] = useState<UniqueIdentifier>()
// ドラッグの開始、移動、終了時に許可するインプット
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const findContainer = (id: UniqueIdentifier) => {
if (id in items) {
return id
}
return Object.keys(items).find((key: string) => items[key].includes(id.toString()))
}
const handleDragStart = (event: DragStartEvent) => {
const { active } = event
const id = active.id.toString()
setActiveId(id)
}
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event
const id = active.id.toString()
const overId = over?.id
if (!overId) return
const activeContainer = findContainer(id)
const overContainer = findContainer(over?.id)
if (!activeContainer || !overContainer || activeContainer === overContainer) {
return
}
setItems((prev) => {
const activeItems = prev[activeContainer]
const overItems = prev[overContainer]
const activeIndex = activeItems.indexOf(id)
const overIndex = overItems.indexOf(overId.toString())
let newIndex
if (overId in prev) {
newIndex = overItems.length + 1
} else {
const isBelowLastItem = over && overIndex === overItems.length - 1
const modifier = isBelowLastItem ? 1 : 0
newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1
}
return {
...prev,
[activeContainer]: [...prev[activeContainer].filter((item) => item !== active.id)],
[overContainer]: [
...prev[overContainer].slice(0, newIndex),
items[activeContainer][activeIndex],
...prev[overContainer].slice(newIndex, prev[overContainer].length),
],
}
})
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
const id = active.id.toString()
const overId = over?.id
if (!overId) return
const activeContainer = findContainer(id)
const overContainer = findContainer(over?.id)
if (!activeContainer || !overContainer || activeContainer !== overContainer) {
return
}
const activeIndex = items[activeContainer].indexOf(id)
const overIndex = items[overContainer].indexOf(overId.toString())
if (activeIndex !== overIndex) {
setItems((items) => ({
...items,
[overContainer]: arrayMove(items[overContainer], activeIndex, overIndex),
}))
}
setActiveId(undefined)
}
return (
<div className='flex flex-row mx-auto'>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContainer id='container1' label='TO DO' items={items.container1} />
<SortableContainer id='container2' label='IN PROGRESS' items={items.container2} />
<SortableContainer id='container3' label='REVIEW' items={items.container3} />
<SortableContainer id='container4' label='DONE' items={items.container4} />
<DragOverlay>{activeId ? <Item id={activeId} /> : null}</DragOverlay>
</DndContext>
</div>
)
}
export default Contaienr
Container.tsxに登場する主なメソッドは以下の通りです。
handleDragStart
関数は、ドラッグが開始されたときに発火します。ドラッグされたアイテムのidを取得し、setActiveId関数を使用してアクティブな要素のidを更新します。
handleDragOver
関数は、ドラッグ可能なアイテムがドロップ可能なコンテナの上に移動したときに発火します。ドラッグされた要素とドロップ先の要素のidを取得し、それぞれが属するコンテナを特定します。移動元と移動先のコンテナが異なる場合、アイテムの位置を変更するためにsetItems関数を使用します。
handleDragEnd
関数は、ドラッグが終了したときに発火します。ドラッグされたアイテムとドロップ先のアイテムのidを取得し、それぞれが属するコンテナを特定します。移動元と移動先のコンテナが同じ場合、アイテムの位置を変更するためにsetItems関数を使用します。
最後に、return()に表示するHTMLのタグを記述していきます。DndContext
コンポーネントでドラッグ&ドロップのコンテキストを作成し、タグ内で管理対象のコンテナをレンダリングしています。SortableContainer
は各コンテナを表し、DragOverlay
はドラッグ中の要素をオーバーレイ表示します。
SortableContainer.tsx
の実装は以下の通りです。
'use client'
import { useDroppable } from '@dnd-kit/core'
import { rectSortingStrategy, SortableContext } from '@dnd-kit/sortable'
import SortableItem from './SortableItem'
const SortableContainer = ({
id,
items,
label,
}: {
id: string
items: string[]
label: string
}) => {
const { setNodeRef } = useDroppable({
id,
})
return (
<div className='w-[calc(33%-5px)]'>
<SortableContext id={id} items={items} strategy={rectSortingStrategy}>
<div
ref={setNodeRef}
className='w-64 min-h-full m-4 bg-gray-200 p-5 mt-2 rounded-md shadow-md'
>
<p className='text-md font-bold text-sky-950'>{label}</p>
{items.map((id: string) => (
<SortableItem key={id} id={id} />
))}
</div>
</SortableContext>
</div>
)
}
export default SortableContainer
SortableContext
はソートを行うためのコンテキストであり、ソート対象をタグ内側に書く必要があります。今回は親コンポーネントから受け取ったitems
をSortableItem
に詰めて表示しています。
'use client'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { UniqueIdentifier } from '@dnd-kit/core'
import Item from './Item'
const Item = ({ id }: { id: UniqueIdentifier }) => {
return (
<div className='w-full h-[50px] flex items-center justify-center my-2.5 border bg-white shadow-md rounded-lg'>
{id}
</div>
)
}
const SortableItem = ({ id }: { id: UniqueIdentifier }) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
{...attributes}
{...listeners}
>
<Item id={id} />
</div>
)
}
export default SortableItem
実装は以上で終わりです。
ローカルサーバーを起動してアプリケーションの動作を確認しましょう。
以下コマンドは、Next.jsアプリをローカルホストの3000ポートで起動するためのものです。
npm run dev
コマンド実行後、http://localhost:3000
にブラウザからアクセスしてみましょう。
見事にTodoタスク管理アプリが表示されましたね。
いかがだったでしょうか。
今回はNext.jsでTrello風のTodoタスク管理アプリを作ってみた | 第1回との二部構成で簡易的なハンズオンを紹介しました。
本記事と合わせて、React(Next.js)の特徴、10分で宣言的UIと命令的UIの違いを完全理解する | サンプルコード付も理解しておくと良いでしょう。
目次