🗂️

【Next.js】ページタブを実装する

2024/01/27に公開

初めに

今回は Next.js で作ったアプリケーションでページタブによる表示ページの変更を行うための実装を共有したいと思います。
もっと良い実装方法などあればご指摘いただけると幸いです。

記事の対象者

  • Next.js 初学者
  • Next.js のページタブを実装したい方

使用技術

  • Next.js : 13.5.6
  • Material UI : 5.15.3

目的

今回は、以下のようにページの切り替えを行うためのページタブを実装することを目的とします。

実装

実装は以下の手順で進めていきたいと思います。

  1. ページの切り替え、保持をする機能を作成
  2. 切り替えた結果を全ページに反映
  3. ヘッダーでタブの切り替え実装

1. ページの切り替え、保持をする機能を作成

まずはページの切り替え、保持を行うための Provider を作成していきます。
コードは以下の通りです。

tab_context.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface TabContextType {
  value: string;
  setValue: React.Dispatch<React.SetStateAction<string>>;
}

const TabContext = createContext<TabContextType | null>(null);

export const useTabContext = () => useContext(TabContext) as TabContextType;

interface TabProviderProps {
  children: ReactNode;
}

export const TabProvider: React.FC<TabProviderProps> = ({ children }) => {
  const [value, setValue] = useState<string>("1");

  return (
    <TabContext.Provider value={{ value, setValue }}>
      {children}
    </TabContext.Provider>
  );
};

ダークモードの切り替えの記事で紹介した実装方法とかなり似ていますが、基本的には createContext で現在のタブの値とタブを変更するための関数を保持するようにしています。

以下の部分では valuesetValue を持つ TabContextType をもとに createContext を行い、それを外部で使用できる形で useTabContext としてエクスポートしています。
このようにすることでこのページ以外でページタブの値を変更することができるようになります。

tab_context.tsx
const TabContext = createContext<TabContextType | null>(null);

export const useTabContext = () => useContext(TabContext) as TabContextType;

以下では作成した TabContext で囲んだ要素を TabProvider としてエクスポートしています。
この TabProvider で囲まれた要素ではページタブの値を保持することができます。
なお、ページタブの初期値を 1 に設定しています。

tab_context.tsx
export const TabProvider: React.FC<TabProviderProps> = ({ children }) => {
  const [value, setValue] = useState<string>("1");

  return (
    <TabContext.Provider value={{ value, setValue }}>
      {children}
    </TabContext.Provider>
  );
};

2. 切り替えた結果を全ページに反映

次に先ほど作成した TabProvider をページ全体に適応させます。
コードは以下の通りです。

_app.tsx
import "./styles/globals.css";
import type { AppProps } from "next/app";
import { TabProvider } from "@/components/tab_context";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <TabProvider>
      <Component {...pageProps} />
    </TabProvider>
  );
}

export default MyApp;

_app.tsx において、 TabProvider でコンポーネントを囲むことで、囲まれた要素の中ではタブの番号を保持、変更することができるようになります。

3. ヘッダーでタブの切り替え実装

次にタブの変更をヘッダーで行うための実装を行いたいと思います。
コードは以下の通りです。
なお、本筋に関係のないスタイリングなどは除外しています。

スタイリングを含むヘッダーのコードはこちら
header/index.tsx
import React from "react";
import Box from "@mui/material/Box";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import CustomIconButton from "@/components/custom_icon_button";
import { ThemeModeButton } from "@/components/theme_mode_button";
import { styled, useTheme } from "@mui/material/styles";
import About from "../about";
import Articles from "../articles";
import Contests from "../contests";
import Skills from "../skills";
import Works from "../works";
import Welcome from "../welcome";
import { useTabContext } from "@/components/tab_context";
import LanguageSwitchButton from "@/components/language_switch_button";

const StyledBox = styled(Box)(({ theme }) => ({
  backgroundColor: theme.palette.mode === "dark" ? "#252529" : "white",
  border:
    theme.palette.mode === "dark" ? "solid grey 0.5px" : "solid #DCDCDC 0.5px",
  boxShadow:
    theme.palette.mode === "dark"
      ? "0px 5px 5px grey[900]"
      : "0px 5px 5px grey[200]",
  borderRadius: "50px",
}));

interface StyledTabsProps {
  children?: React.ReactNode;
  value: string;
  onChange: (event: React.SyntheticEvent, newValue: string) => void;
}

const StyledTabs = styled((props: StyledTabsProps) => (
  <Tabs
    {...props}
    TabIndicatorProps={{ children: <span className="MuiTabs-indicatorSpan" /> }}
    centered
  />
))(({ theme }) => ({
  "& .MuiTabs-indicator": {
    display: "flex",
    justifyContent: "center",
    backgroundColor: "transparent",
  },
  "& .MuiTabs-indicatorSpan": {
    maxWidth: 40,
    width: "100%",
    backgroundImage:
      theme.palette.mode === "dark"
        ? "linear-gradient(90deg, #252529, #2CD4BF, #252529)"
        : "linear-gradient(90deg, white, #2CD4BF, white)",
  },
}));

interface StyledTabProps {
  label: string;
  value: string;
}

const StyledTab = styled((props: StyledTabProps) => (
  <Tab disableRipple {...props} />
))(({ theme }) => ({
  color: theme.palette.mode === "dark" ? "#DCDCDC" : "#252529",
  height: "30px",
  textTransform: "none",
  fontWeight: theme.typography.fontWeightRegular,
  fontSize: theme.typography.pxToRem(10),
  "&.Mui-selected": {
    color: "#2CD4BF",
  },
  "&.Mui-focusVisible": {
    backgroundColor: "#2CD4BF",
  },
}));

export default function Header() {
  const { value, setValue } = useTabContext();
  const theme = useTheme();

  const handleChange = (event: React.SyntheticEvent, newValue: string) => {
    setValue(newValue);
  };

  const renderTabContent = (value: string) => {
    switch (value) {
      case "1":
        return <Welcome />;
      case "2":
        return <About />;
      case "3":
        return <Articles />;
      case "4":
        return <Skills />;
      case "5":
        return <Works />;
      case "6":
        return <Contests />;
      default:
        return <Welcome />;
    }
  };

  return (
    <div style={{ margin: 0 }}>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          backgroundColor: theme.palette.mode === "dark" ? "#18181B" : "white",
          padding: "40px",
        }}
      >
        <CustomIconButton
          backgroundImageUrl="https://lh3.googleusercontent.com/pw/ABLVV87QQxcXbkOrLrpMfPDNL311hkMBlZwcefuWB9lfcvRDEQog3kntHhpGiLAqbeaKFGUY8PCJV2AE6hmOn53vDdtbI0YpcZ1zua67wOuwBc6jSB21H0D36JVpp8ceQmyOxofvls04DaFL6UuOICP2i2ON=w500-h500-s-no-gm?authuser=0"
          onClick={function (): void {
            console.log("icon button clicked");
          }}
        />
        <StyledBox>
          <StyledTabs value={value} onChange={handleChange}>
            <StyledTab label="Home" value="1" />
            <StyledTab label="About" value="2" />
            <StyledTab label="Articles" value="3" />
            <StyledTab label="Skills" value="4" />
            <StyledTab label="Works" value="5" />
            <StyledTab label="Contests" value="6" />
          </StyledTabs>
        </StyledBox>
        <div style={{ display: "flex", alignContent: "center" }}>
          <LanguageSwitchButton />
          <ThemeModeButton />
        </div>
      </div>
      <div>{renderTabContent(value)}</div>
    </div>
  );
}
export default function Header() {
  const { value, setValue } = useTabContext();

  const handleChange = (event: React.SyntheticEvent, newValue: string) => {
    setValue(newValue);
  };

  const renderTabContent = (value: string) => {
    switch (value) {
      case "1":
        return <Welcome />;
      case "2":
        return <About />;
      case "3":
        return <Articles />;
      case "4":
        return <Skills />;
      case "5":
        return <Works />;
      case "6":
        return <Contests />;
      default:
        return <Welcome />;
    }
  };

  return (
    <div style={{ margin: 0 }}>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          padding: "40px",
        }}
      >
        <CustomIconButton
          backgroundImageUrl="path/to/header/icon"
          onClick={function (): void {
            // when header icon pushed
          }}
        />
        <Box>
          <Tabs value={value} onChange={handleChange}>
            <Tab label="Home" value="1" />
            <Tab label="About" value="2" />
            <Tab label="Articles" value="3" />
            <Tab label="Skills" value="4" />
            <Tab label="Works" value="5" />
            <Tab label="Contests" value="6" />
          </Tabs>
        </Box>
        <div style={{ display: "flex", alignContent: "center" }}>
          <LanguageSwitchButton />
          <ThemeModeButton />
        </div>
      </div>
      <div>{renderTabContent(value)}</div>
    </div>
  );
}

詳しく見ていきましょう。

以下の部分では useTabContext を用いて、 tab_context.tsx で定義した、タブを操作するためのフックを呼び出しています。
また、呼び出したフックのうち、タブがタップされた時の操作として、タップされたタブの番号を受け取り、それをsetValue で新たなタブの番号としてセットしています。

  const { value, setValue } = useTabContext();

  const handleChange = (event: React.SyntheticEvent, newValue: string) => {
    setValue(newValue);
  };

以下の部分では、タブの番号を受け取り、その番号によって表示させるコンポーネントを変更する処理をswitch文で記述しています。

  const renderTabContent = (value: string) => {
    switch (value) {
      case "1":
        return <Welcome />;
      case "2":
        return <About />;
      case "3":
        return <Articles />;
      case "4":
        return <Skills />;
      case "5":
        return <Works />;
      case "6":
        return <Contests />;
      default:
        return <Welcome />;
    }
  };

以下の部分では、タブの実装を行なっています。
Tabsvalue にタブの番号の状態を、 onChange に先ほど定義したタブの番号を変更する処理を記述することで、それぞれのタブの変更を検知することができます。
また、それぞれのタブに value を指定しておくことでそれぞれのタブを判別することができるようになります。

<Box>
  <Tabs value={value} onChange={handleChange}>
    <Tab label="Home" value="1" />
    <Tab label="About" value="2" />
    <Tab label="Articles" value="3" />
    <Tab label="Skills" value="4" />
    <Tab label="Works" value="5" />
    <Tab label="Contests" value="6" />
  </Tabs>
</Box>

最後に、以下の部分で、タブの番号を受け取ることで、番号に応じたコンポーネントを表示させるようにしています。

<div>{renderTabContent(value)}</div>

これで「目的」の章にあった見本のように、タブによるページの操作が可能になったかと思います。
この場合の操作では Link を用いた画面遷移とは異なり、URLの変更もないため、スムーズにページの変更を行うことができます。
今回はヘッダーのみの実装を行いましたが、基本的にフッターでも同様に処理を記述することでタブの操作の実装は可能です。

今回実装した内容は以下のリポジトリで確認可能です。
https://github.com/Koichi5/portfolio-next

リポジトリ内の各ページ

まとめ

最後まで読んでいただいてありがとうございました。

今回は Next.js × Material UI のページタブ実装について簡単にまとめました。
誤っている点や他の実装方法等あればご指摘いただけると幸いです。

参考

https://zenn.dev/yuta_extend/articles/cbffef947d8320

Discussion