🎉

Next.js + MUI で簡単な管理画面テンプレートを作ってみる(その2)

2024/03/17に公開

前回からの続きです。

Next.jsとMaterial UIを使ってそれっぽい管理画面の見た目までできたので、今回は機能面を追加します。
サイドバーからメニューを選択したらページ遷移するようにしてみましょう。

おさらい

前回は以下のような画面を作りました。デザインはMUIのサイトにあったDrawerのサンプルコードそのままです。layoutのみ作っており、メニューをクリックしても何も起こりません。

前回の画面
前回の画面

ナビゲーションバーを修正する

タイトルにClipped drawerと書かれているのでMUI 管理画面に変更し、タイトルをクリックしたらトップページに遷移するようにします。

src/app/_components/navigationbar.tsx
"use client";
import { AppBar, Toolbar, Typography, Link } from "@mui/material";
import React from "react";

const NavigationBar = () => {
  return (
    <AppBar
      position="fixed"
      sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
    >
      <Toolbar>
        <Typography variant="h6" noWrap component="div">
          <Link href="/" underline="none" color="inherit">
            MUI 管理画面
          </Link>
        </Typography>
      </Toolbar>
    </AppBar>
  );
};

export default NavigationBar;

ついでにlayout.tsxに定義されているmetadataのtitleも変えておきましょう。

src/app/layout.tsx (抜粋)
export const metadata: Metadata = {
  title: "MUI 管理画面",
  description: "Material-UI Admin Page Example",
};

これでナビゲーションバーのタイトルが変更され、クリックするとトップページに戻るようになりました。ついでにブラウザのタブに表示されるタイトルも変更されました。

タイトル変更
タイトル変更

サイドバーを修正する

Page2を作る

前回Page1というページを作りましたが、それをコピーしてPage2を作りましょう。この2つのページをサイドバーのメニューから切り替わるようにします。

src/app/page2/page.tsx
import React from "react";

const Page2 = () => {
  return <h1>Page2</h1>;
};

export default Page2;

http://localhost:3000/page2にアクセスすると以下のようになります。

ページ2
ページ2

ページ遷移を実装する

ページ遷移を実装する方法はいくつかあります。現状のコードに使われている<ListItemButton>にonClickイベントハンドラを設定し、router.push()で遷移しても良いのですが、ここは一番簡単にLinkで遷移することにします。

まず、サイドバー内には`Divider`の上下に2つのメニューがありますが、Diverから下は今回は不要なので削除してしまいます。そして上側のメニューの中で

<ListItemText primary={text} />

となっている部分を

<Link href="/page1" underline="none" color="inherit">
    Page1
</Link>

に変更します。URLやメニューの表示文字がハードコーディングになっていますが、これは後で直します。全体としては以下のようなコードになります。

src/app/_components/sidebar.tsx
import {
  Box,
  Drawer,
  Link,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  Toolbar,
} from "@mui/material";
import InboxIcon from "@mui/icons-material/MoveToInbox";
import MailIcon from "@mui/icons-material/Mail";
import React from "react";

const drawerWidth = 240;

const SideBar = () => {
  return (
    <Drawer
      variant="permanent"
      sx={{
        width: drawerWidth,
        flexShrink: 0,
        [`& .MuiDrawer-paper`]: {
          width: drawerWidth,
          boxSizing: "border-box",
        },
      }}
    >
      <Toolbar />
      <Box sx={{ overflow: "auto" }}>
        <List>
          {["Inbox", "Starred", "Send email", "Drafts"].map((text, index) => (
            <ListItem key={text} disablePadding>
              <ListItemButton>
                <ListItemIcon>
                  {index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
                </ListItemIcon>
                <Link href="/page1" underline="none" color="inherit">
                  Page1
                </Link>
              </ListItemButton>
            </ListItem>
          ))}
        </List>
      </Box>
    </Drawer>
  );
};

export default SideBar;

すると、以下のようにPage1が4つ表示され、どれをクリックしてもPage1に遷移するようになったはずです。

リンクに変更

遷移先を変える

では、ハードコーディングされている遷移先を変更できるようにしていきましょう。ついでにメニューのアイコンも変更できるようにします。

sidebar.tsxの最初に以下のコードを入れ、表示するメニューの名前、リンク先、アイコンを配列で定義します。

src/app/_components/sidebar.tsx (抜粋)
type MenuItem = {
  name: string;
  url: string;
  icon: React.ReactNode;
};
const menuList: MenuItem[] = [
  { name: "ページ1", url: "/page1", icon: <BeachAccessIcon /> },
  { name: "ページ2", url: "/page2", icon: <CoffeeIcon /> },
];

そして上記定義を参照して、配列分のメニューを作成します。

src/app/_components/sidebar.tsx (抜粋)
        <List>
          {menuList.map(({ name, url, icon }: MenuItem) => (
            <ListItem key={name} disablePadding>
              <ListItemButton>
                <ListItemIcon>{icon}</ListItemIcon>
                <Link href={url} underline="none" color="inherit">
                  {name}
                </Link>
              </ListItemButton>
            </ListItem>
          ))}
        </List>

全体は以下のようになります。

src/app/_components/sidebar.tsx
import {
  Box,
  Drawer,
  Link,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  Toolbar,
} from "@mui/material";
import BeachAccessIcon from "@mui/icons-material/BeachAccess";
import CoffeeIcon from "@mui/icons-material/Coffee";
import React from "react";

type MenuItem = {
  name: string;
  url: string;
  icon: React.ReactNode;
};
const menuList: MenuItem[] = [
  { name: "ページ1", url: "/page1", icon: <BeachAccessIcon /> },
  { name: "ページ2", url: "/page2", icon: <CoffeeIcon /> },
];

const drawerWidth = 240;

const SideBar = () => {
  return (
    <Drawer
      variant="permanent"
      sx={{
        width: drawerWidth,
        flexShrink: 0,
        [`& .MuiDrawer-paper`]: {
          width: drawerWidth,
          boxSizing: "border-box",
        },
      }}
    >
      <Toolbar />
      <Box sx={{ overflow: "auto" }}>
        <List>
          {menuList.map(({ name, url, icon }: MenuItem) => (
            <ListItem key={name} disablePadding>
              <ListItemButton>
                <ListItemIcon>{icon}</ListItemIcon>
                <Link href={url} underline="none" color="inherit">
                  {name}
                </Link>
              </ListItemButton>
            </ListItem>
          ))}
        </List>
      </Box>
    </Drawer>
  );
};

export default SideBar;

すると以下のようにページ1とページ2のメニューが表示され、クリックするとそれぞれのページへ遷移するようになりました。アイコンも変更できました。

メニュー修正
メニュー修正後

これでmenuList[]配列の定義を編集することでメニュー本体のコードに手を入れなくてもメニューの追加・変更ができるようになりました。

アイコンについて

メニューに表示しているアイコンはsidebar.tsxの上部で

src/app/_components/sidebar.tsx (抜粋)
import BeachAccessIcon from "@mui/icons-material/BeachAccess";
import CoffeeIcon from "@mui/icons-material/Coffee";

のようにインポートしています。これらのインポート文を作成する方法について説明します。以下のページでアイコンの一覧を見ることができます。

https://mui.com/material-ui/material-icons/

アイコンのリスト
アイコンのリスト

ここで目的のアイコンを探してクリックします(かなりたくさんあるので探すのが大変です...)。すると、以下のようなダイアログが表示され、上部にインポート文が表示されるのでそれをコピーして使ってください。

アイコン選択

メニューを選択状態にする

メニューからページ遷移ができるようになりましたが、今のままだと遷移後にメニューが非選択状態になってしまいます。メニューを選択状態に保つようにしましょう。

sidebar.tsxを編集する

メニューの選択、非選択を切り替えるには、ListItemButtonのselectedパラメータにtrueまたはfalseを渡せば良いです。

今回は以下のように、メニューのurlから選択状態を返すisSelectedというメソッドを実装することにします。

src/app/_components/sidebar.tsx (抜粋)
 <ListItemButton selected={isSelected(url)}>

実装は以下のようになります。

src/app/_components/sidebar.tsx (抜粋)
  const pathname = usePathname();
  const isSelected = (url: string) => {
    if (pathname === url || pathname.startsWith(url + "/")) {
      return true;
    }
    return false;
  };

まずusePathname()で現在表示しているページのパス名をとってきます。パス名とは、現在表示しているページがhttp://localhost:3000/page1であれば、/page1の部分になります。今回の実装ではパス名が/page1でも/page1/でも/page1/child1でも/page1に対応するメニューを選択する仕様になっています。
なお、usePathname()を使う場合には先頭で"use client";を宣言しなければならないので注意してください。

sidebar.tsxの全体は以下のようになります。

src/app/_components/sidebar.tsx
"use client";
import {
  Box,
  Drawer,
  Link,
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  Toolbar,
} from "@mui/material";
import BeachAccessIcon from "@mui/icons-material/BeachAccess";
import CoffeeIcon from "@mui/icons-material/Coffee";
import React from "react";
import { usePathname } from "next/navigation";

type MenuItem = {
  name: string;
  url: string;
  icon: React.ReactNode;
};
const menuList: MenuItem[] = [
  { name: "ページ1", url: "/page1", icon: <BeachAccessIcon /> },
  { name: "ページ2", url: "/page2", icon: <CoffeeIcon /> },
];

const drawerWidth = 240;

const SideBar = () => {
  const pathname = usePathname();
  const isSelected = (url: string) => {
    if (pathname === url || pathname.startsWith(url + "/")) {
      return true;
    }
    return false;
  };
  return (
    <Drawer
      variant="permanent"
      sx={{
        width: drawerWidth,
        flexShrink: 0,
        [`& .MuiDrawer-paper`]: {
          width: drawerWidth,
          boxSizing: "border-box",
        },
      }}
    >
      <Toolbar />
      <Box sx={{ overflow: "auto" }}>
        <List>
          {menuList.map(({ name, url, icon }: MenuItem) => (
            <ListItem key={name} disablePadding>
              <ListItemButton selected={isSelected(url)}>
                <ListItemIcon>{icon}</ListItemIcon>
                <Link href={url} underline="none" color="inherit">
                  {name}
                </Link>
              </ListItemButton>
            </ListItem>
          ))}
        </List>
      </Box>
    </Drawer>
  );
};

export default SideBar;

動かすと以下のようになります。

ページ1を選択
ページ1を選択

ページ2を選択
ページ2を選択

無事メニューが選択状態になりました。また、メニューから遷移した場合だけでなく、ブラウザのアドレスバーに直接URLを入力して表示した場合にもちゃんとメニューが選択状態になります。

子ページで動作を確かめる

Page1の下にChild1というページを作ります。また、Page1にはChild1へのリンクを付けます。ついでに、わかりやすいようにそれぞれにパン屑リストを表示しましょう。

src/app/page1/page.tsx
import { Breadcrumbs, Link, Typography } from "@mui/material";

import React from "react";

const PageOne = () => {
  return (
    <>
      <Breadcrumbs aria-label="breadcrumb">
        <Typography color="text.primary">ページ1</Typography>
      </Breadcrumbs>
      <h1>Page1</h1>
      <p>
        <Link href="/page1/child1">Child1 </Link>
      </p>
    </>
  );
};

export default PageOne;
src/app/page1/child1/page.tsx
import { Breadcrumbs, Link, Typography } from "@mui/material";
import React from "react";

const Child1 = () => {
  return (
    <>
      <Breadcrumbs aria-label="breadcrumb">
        <Link underline="hover" color="inherit" href="/page1">
          ページ1
        </Link>
        <Typography color="text.primary">Child1</Typography>
      </Breadcrumbs>
      <h1>Child1</h1>
    </>
  );
};

export default Child1;

表示を確かめます。

ページ1
ページ1

ページ1を表示しました。メニューが選択状態になっています。child1へのリンクをクリックしてみます。

Child1
Child1

Child1ページに遷移しました。メニューはページ1が選択されたままになっています。

まとめ

MUIを使って管理画面テンプレートを簡単に作る例でした。今回のコードは以下で公開しています。

https://github.com/haru/admin-ui/releases/tag/article-part2

Discussion