0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(フロントエンド実装編③)~サイドバー作成~

0
Posted at

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その24)

0. 初めに

こんにちは!
このシリーズでは、見習いエンジニアの僕が一からWebアプリケーションを開発する様子をお届けしています!

2週間もお休みしてすみませんでした!!
また、モチベが下がりつつあります💦

やはり、一つのアプリを何か月も作るとモチベが下がりますね。
ですが、最近ようやく復活しつつあるので、今後にご期待ください!

前回の続きということで、今日もフロントエンドを実装していきます!

今回は、サイドバーを作成したいと思います!

サイドバーとは

その名の通り、画面の左右どちらかに表示されているもので、「ログイン」や「ログアウト」、「マイページへの遷移」などの便利だけど、ヘッダーに乗せるほどではないメニューを選べるものっていうイメージです。

例: YouTube
https://www.youtube.com/
image.png

1. ブランチ運用

今日も、ローカルのdevelopを最新化して、新規ブランチを切って作業します。
ブランチ名は、feature/frontend/layout-base/sidebarにしましょう。

作業が完了したら、コミット・プッシュを忘れずに行いましょう!

2. 画面デザイン

例によって、今日の完成のイメージを持っておきましょう。

今回のお手本はこちらです!
image.png
image.png

なぜ二つあるかと言いますと、ログインしている状態とログアウトしている状態でメニューを変化させたかったからです。

3. ログイン状態共有

そのためには、ログイン・ログアウトの状態の情報をReact側に共有しておく必要があります。

これまでと同様に、Laravelのコントローラーでページごとにpropsとして渡すことも可能ですが、もっと良い方法があります。

React全体でユーザー情報を共有して使えるようにする方法があります。

というのも、今日作るサイドバー以外でもログインしているときとしていないときで表示を切り替えたい場面って今後たくさん出てきそうですよね。

そのたびごとにそのページたちに対して毎回対応するコントローラーに同じpropsで渡す処理を書くのは大変です。

それを実現する方法が、Middleware\HandleInertiaRequests.phpshare()メソッドを利用する方法です。

詳しくはこちらをご覧ください。
https://inertiajs.com/shared-data

実は、バックエンド編でInertiaをセットアップした際に自動で作られているのですが、一応以下のようなメソッドがあることを確認してください。

\project-root\src\app\Http\Middleware\HandleInertiaRequests.php
    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user(),
            ],
        ];
    }

これは、React側からLaravel側に対してリクエストをすると共有データとして登録されている配列にあるデータをReact側のどのページからも参照することができるようにするものです。

既に、'auth''user'という値が設定されています。

これにより、React側でauth.userのようにすることで、ユーザー情報を取得できます。

また、...parent::share($request),の部分についてですが、これは継承元のmiddlewareの配列の展開(スプレッド構文)であり、middlewareはLaravel, Inertia側で既に用意されているものであるため、中身を深く理解する必要はなさそうです。

4. AppLayoutでの状態管理

次に、AppLayoutでユーザーのログイン・ログアウト及びサイドバーの開閉状態を管理するように修正したいと思います。

ここからようやくReactっぽいことをしていきたいと思います。

つまり、React Hooksを用いた状態管理をしていきたいと思います!

React Hooksとは、状態管理をするための関数(厳密には異なりますが)みたいなものだと思ってもらえればよいかなと思います。

そして、状態管理というのは、今回で言うと「サイドバーが開いている or 閉じている」というような画面上で持っておくべき情報を保存しておき、それを利用して何か画面の様子を切り替えたり、動かしたりするためのものです。

例えばYouTubeであれば、「再生ボタンが押されている or ストップボタンが押されている」とか、カウンターアプリなら「現在のカウント数」とかを管理するのに利用されています。

そんなReact Hooksの中でも一番有名なもので、今日も使用する予定のuseStateというやつがあります!

今日は、そのほかにも、useEffectuseCallbackを使いたいと思います。

初めての人やまだ慣れていない人は以下の記事に目を通しておくとよいでしょう!
とても分かりやすかったです。

僕もuseCallbackは正直良くわかっていなかったので、全部読みました。(笑)

https://envader.plus/article/443
https://envader.plus/article/444
https://envader.plus/article/497

では、状態管理をするために、React Hooksインポートしましょう!

\project-root\src\resources\js\Layouts\AppLayout.jsx
import { useState, useCallback, useEffect } from 'react'; // 追加

さらに、Sidebarを追加します!
これは、後で作るので今はエラーが出ますが、気にしないでください。

\project-root\src\resources\js\Layouts\AppLayout.jsx
import Sidebar from "../Components/Sidebar"; // 追加: 後で作る

では、いよいよ中身を書いていきます!

今回管理したい状態は2つです。

  • ユーザーがログインしているかどうか
  • サイドバーが開いているかどうか

まず、ユーザーのログイン状態についてです。

ユーザーの情報は、InertiaのShared dataで定義されていれば、usePage()を使えば取得できます!

それをもとに、ログインしているか・ログアウトしているかの情報を真偽値(true or false)で管理します。
それをisLoggedInという変数に代入しておきます。

export default function AppLayoutの中のreturnの上に書いていきましょう。

\project-root\src\resources\js\Layouts\AppLayout.jsx
export default function AppLayout({ children, title }) {
  const { props } = usePage(); // 追加
  const isLoggedIn = !!props?.auth?.user; // 追加

  return (
      // ...
  );
}

isLoggedInは、ログインしていればtrueに、ログアウトしていればfalseになります。

!!は、論理否定演算子(!)を二つ連ねたものです。
!は、真偽値をひっくり返すものでした。

では、なぜ二重にするのかと言いますと、JavaScriptの特性があるからです。

JavaScriptでは、undefined0''といった値を自動でfalseに変換してくれます。
これらは、falsyな値と呼ばれます。

falseという値をそのまま明示的代入したい場合は、!で一度trueにしてから再度!でひっくり返すというワザがあるらしいです。

まとめてくれている記事があったのでご紹介しておきますね。
https://zenn.dev/hosoiroji/articles/double-exclamation-mark

次に、サイドバーの開閉状態についてです。
先ほど追加したログイン状態管理のHooksの下に追加しておきます。

\project-root\src\resources\js\Layouts\AppLayout.jsx
export default function AppLayout({ children, title }) {
  const { props } = usePage();
  const isLoggedIn = !!props?.auth?.user;


  const [isSidebarOpen, setIsSidebarOpen] = useState(false); // 追加: サイドバーの開閉状態
  const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []); // 追加: サイドバーの開閉を設定
  
  return (
      // ...
  );
}

ついに出ました!useState()です。

ちょっと独特な書き方をしますが、状態管理をしたい値を〇〇という変数に代入するよう各場合、set○○という状態を更新するための関数も一緒に定義します。

その際、useState()のかっこの中に初期値を渡します。

\project-root\src\resources\js\Layouts\AppLayout.jsx
const [isSidebarOpen, setIsSidebarOpen] = useState(false); // 追加: サイドバーの開閉状態

今回の場合だと、サイドバーが開いているかどうかを真偽値で管理したいので、それをisSidebarOpenという値に代入します。

最初は、閉じている状態から始めたいので、初期値としてfalseを渡しています。

そして、開閉状態を切り替えたい場合は、setIsSidebarOpenを呼び出せばよいということになります!

※ちなみに、Reactでは、状態管理をしている変数に更新したい値を直接代入することは禁止されています。

確かに、setIsSidebarOpenとかいうよくわからん回りくどい書き方をしなくても、例えばサイドバーを開きたいときは以下のように直接isSidebarOpentrueを代入すればよくね?と思ってしまいます。

isSidebarOpen = true

しかし、constで定義している以上、そもそもこの書き方はエラーが起きてしまいます。

また、それよりもいろいろと奥が深い理由があるらしいです。

興味のある方はぜひ調べてみてください。
この記事では、そこまで深入りはしません。

React初心者の方はとりあえず、直接代入は禁止なんだと覚えておいてもらえればよいと思います!

useStateでは、stateに更新したい値を直接代入してはならない。

そして、このsetIsSidebarOpenの呼び出し方ですが、今回はsetSidebarOpenという別の関数をuseCallback経由で用いることで呼び出してみたいと思います。

\project-root\src\resources\js\Layouts\AppLayout.jsx
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []); // 追加: サイドバーの開閉を設定

引数oepnに真偽値を代入して使いたいと思います!

初回レンダリング時に読み込まれればよいので、依存配列は空にしておきましょう。

依存配列については後で軽く解説を入れるので、よくわからない場合はいったん飛ばしてもらっても大丈夫です。

では、今作った状態管理を利用したjsxをreturn文の中に追加して使っていきましょう。

\project-root\src\resources\js\Layouts\AppLayout.jsx
export default function AppLayout({ children, title }) {
  // ユーザーの認証状態を管理
  const { props } = usePage();
  const isLoggedIn = !!props?.auth?.user;

  // サイドバーの開閉状態を管理
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
  const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);

  return (
    <div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
      <Head title={title} />
      {/* Header 追加: isSidebarOpen */}
      <Header title={title} onOpenSidebar={() => setSidebarOpen(true)} />

      {/* Main Content Area */}
      <main className="flex-1 bg-transparent flex">
        <div
          className="
            mx-auto
            w-full
            max-w-container
            px-[clamp(16px,4vw,32px)]
            py-6
            bg-[#EEF5F9]
            flex-1
          "
        >
          {children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
        </div>
      </main>
      {/* 追加: サイドバー */}
      <Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
    </div>
  );
}

サイドバーは前回作ったヘッダー内のハンバーガーアイコンをクリックしたときに開くようにしたいので、開閉情報をヘッダーに渡す必要があります。

\project-root\src\resources\js\Layouts\AppLayout.jsx
      {/* Header 追加: isSidebarOpen */}
      <Header title={title} onOpenSidebar={() => setSidebarOpen(true)}

Header側からonOpenSidebarというpropsを受け取れるようにして(この後実装します)、それに対して先ほど作ったsetSidebarOpen()を渡します。

ハンバーガーアイコンがクリックされるとヘッダーが開くようにしたいので、当然isSidebarOpentrueにする必要があるため、setSidebarOpen(true)のようにします!

さらにサイドバーに対して、閉じるボタンを用意してクリックできるようにしたいので、当然サイドバーに対しても開閉状態を渡す必要があります。
また、ログイン状態によって表示内容を変えたいので、ユーザーの認証状態も渡します。

\project-root\src\resources\js\Layouts\AppLayout.jsx
     {/* 追加: サイドバー */}
      <Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />

サイドバー(この後作成します)には、開閉状態を管理するisOpenと閉じる操作を起こすonClose、認証状態を管理するisLoggedInという三つを受け取るpropsを用意していおいて、それらに対してそれぞれisSidebarOpen() => setSidebarOpen(false)isLoggedInを渡します。

これにて、\Layouts\AppLayout.jsxの修正はいったん完了です!

5. Header修正

次に、\Components\Header.jsxを修正していきたいと思います。

と言ってもやることは簡単で、先ほどAppLayoutから渡すようにしたonOpenSidebarを受け取って、ハンバーガーアイコンがクリックされたときにそれが発動するようにしてあげればOKです!

\project-root\src\resources\js\Components\Header.jsx
import logo from '../Assets/logo/header.svg';
import hamburgerIcon from '../Assets/icons/hamburger.svg';
import { Link } from '@inertiajs/react';

const Header = ({ title, onOpenSidebar }) => { // 追加: onOpenSidebar を受け取る
  return (
    <header
      className="
        relative
        h-14
        flex
        items-center
        justify-between
        px-6
        bg-[#EEF5F9]
        after:content-['']
        after:absolute
        after:bottom-0
        after:left-0
        after:h-[3px]
        after:w-full
        after:bg-[linear-gradient(to_right,rgba(226,145,140,0.8),rgba(215,145,232,0.8),rgba(118,192,235,0.8))]
      "
    >
      {/* 左:ロゴ */}
      <div className="flex items-center">
        <Link href={route('labs.home')} aria-label="トップページへ">
          <img src={logo} alt="App Logo" className="h-9 w-auto" />
        </Link>
      </div>

      {/* 中央:タイトル */}
      <h1 className="absolute left-1/2 -translate-x-1/2 text-xl font-semibold text-black">
        {title}
      </h1>

      {/* 右:ハンバーガー onClickにonOpenSidebarを追加 */}
      <button
        className="ml-auto flex items-center"
        aria-label="メニューを開く"
        onClick={onOpenSidebar}
      >
        <img
          src={hamburgerIcon}
          alt="メニュー"
          className="h-7 w-7 hover:opacity-80 transition"
        />
      </button>
    </header>
  );
};

export default Header;

6. Sidebar作成

続いて、サイドバーを作ります。
ここが今日のメインです!

Header.jsxと同じ階層にSidebar.jsxというファイルを新規作成してください。

ファイルを作成出来たら、まず、使用するアイコンたちを呼び出しましょう。
クリック時にリンクを移動させたいので、Linkも一緒にインポートします。

\project-root\src\resources\js\Components\Sidebar.jsx
import { Link } from '@inertiajs/react';
import close from '../Assets/icons/sidebar/close.svg';
import home from '../Assets/icons/sidebar/home.svg';
import mypage from '../Assets/icons/sidebar/mypage.svg';
import bookmark from '../Assets/icons/sidebar/bookmark.svg';
import ranking from '../Assets/icons/sidebar/ranking.svg';
import logout from '../Assets/icons/sidebar/logout.svg';
import login from '../Assets/icons/sidebar/login.svg';
import register from '../Assets/icons/sidebar/register.svg';

これらのアイコンを共有しますので、ダウンロードして指定されたディレクトリに格納してください。

前回までに用意している\project-root\src\resources\js\Assets直下にsidebarというフォルダを作って、その中に以下のアイコンたちを格納してください。

sidebar

今回もReact IconsFigmaで作りました!
自分でも作ってみたいという方はぜひ挑戦してみてください!(; ・`д・´)

Viteを利用しているため、画像ファイルをまるでReactコンポーネントかのように扱えるのでした。

次にSidebar関数コンポーネントを定義・エクスポートしましょう。
AppLayoutから渡されるpropsたちを受け取れるようにします。

\project-root\src\resources\js\Components\Sidebar.jsx
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {}

中身を書いていきましょう。

export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
	// メニュー定義
	const items = isLoggedIn
		? [
				{ label: 'ホーム', href: route('labs.home'), icon: home },
				{ label: 'マイページ', href: route('mypage.index'), icon: mypage },
				{ label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
				{ label: 'ランキング', href: null, icon: ranking },
				{ label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
			]
		: [
				{ label: 'ホーム', href: route('labs.home'), icon: home },
				{ label: '新規登録', href: route('register'), icon: register },
				{ label: 'ログイン', href: route('login'), icon: login },
				{ label: 'ランキング', href: null, icon: ranking },
			];
}

サイドバーに表示するメニューをオブジェクト型で定義して、変数itemsに代入しています。

三項演算子を利用することで、受け取ったpropsのisLoggedInによって、内容を変えます。
これにより、ログイン・ログアウト状態で表示されるメニューを切り替えることができます!

三項演算子が怪しい方は復習しておきましょう。
JavaScriptの基本文法です。

(怪しい人だけ)三項演算子について復習しておきましょう。

オブジェクトの構成としては、labelhrefmethodiconの四つのプロパティを用意しました。

  • label: 表示する文字
  • href: 遷移先のリンク。LaravelのZiggyルートヘルパ※
  • method: メソッドの種類。ログアウトのみで使用
  • icon: 表示するアイコン。インポート時の名前を用いる

バックエンド実装編で作成したweb.phpにおいて名前付きルーティングで定義したものがそのまま使える

ログアウト以外は、それぞれのページに遷移するだけで済みますが、ログアウト処理はPOSTメソッド通信が必要なので、それだけのためにmethodプロパティを用意しました。

ちなみに、ランキング機能は未実装です。
これがないとなんか寂しかったので付けました。(笑)
なので特に気にしないでください。

続いて、return分の中身を追加していきましょう。

\project-root\src\resources\js\Components\Sidebar.jsx
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
  // メニュー定義
  const items = isLoggedIn
    ? [
        { label: 'ホーム', href: route('labs.home'), icon: home },
        { label: 'マイページ', href: route('mypage.index'), icon: mypage },
        { label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
        { label: 'ランキング', href: null, icon: ranking },
        { label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
      ]
    : [
        { label: 'ホーム', href: route('labs.home'), icon: home },
        { label: '新規登録', href: route('register'), icon: register },
        { label: 'ログイン', href: route('login'), icon: login },
        { label: 'ランキング', href: null, icon: ranking },
      ];

  return (
    <>
      {/* 本体 */}
      <aside
        role="dialog"
        aria-modal="true"
        className={`
          fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
          transform transition-transform duration-300
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
          flex flex-col
        `}
      >
      </aside>
    </>
  );
}

追加したものは、ひとまず三つです。

一つ目はこのサイドバー本体です。
こちらは、asideタグを使用しています。

divタグでよくね?」と思ってしまいがちですが、結構メリットもあるようです。
詳しくは、以下を参照ください。
https://www.sejuku.net/blog/227859

role="dialog"aria-modal="true"の部分は、例のアクセシビリティのスクリーンリーダーへの対応なので、そこまで気にしなくて大丈夫です。

className属性の中身を見てみましょう。

fixed right-0 top-0で、画面右上に張り付けることができます。
これにより、スクロールしても動かなくなります。
https://v3.tailwindcss.com/docs/position#fixed-positioning-elements

h-dvhで、高さを画面いっぱいに広げることができます。
https://v3.tailwindcss.com/docs/height#dynamic-viewport-height

開閉のアニメーション用にtransformを定義できます!
transition-transform duration-300で、300ms(0.3秒間ですね)でアニメーションが動きます。
これがないと一瞬でサイドバーが出たり消えたりしてしまいます。

${isOpen ? 'translate-x-0' : 'translate-x-full'}の部分では、今回二度目の三項演算子が使われています。

${ 変数名 }とするとJavaScriptの変数を展開できます。
基本文法なので、おそらく大丈夫ですよね..?

怪しいという方は復習ですね。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Template_literals

translate-x-0つまり閉じている状態から、translate-x-fullすなわち開いている状態に0.3秒間のアニメーションを付けることができます。
これにより、滑らかな動作が実現できます!

次に、ヘッダーの部分に重なるバツ印を追加しましょう。

\project-root\src\resources\js\Components\Sidebar.jsx
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
  // メニュー定義
  // ...
  
  return (
    <>
      {/* 本体 */}
      <aside
        role="dialog"
        aria-modal="true"
        className={`
          fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
          transform transition-transform duration-300
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
          flex flex-col
        `}
      >
        {/* ヘッダー(バツ印) */}
        <div className="flex items-center justify-end px-4 h-14">
          <button
            type="button"
            onClick={onClose}
            className="p-2"
            aria-label="メニューを閉じる"
            autoFocus
          >
            <img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
          </button>
        </div>
      </aside>
    </>
  );
}

onClick={onClose}と書くことで、クリックされたときにonCloseが呼ばれるようになります!

ちょっと処理の流れが分かりにくいかもしれないので、くどいかもしれませんが書いておきますね!

①ユーザーによって、バツ印がクリックされる
②onClickイベントが発火して、onCloseが呼びだされる!

\project-root\src\resources\js\Components\Sidebar.jsx
onClick={onClose}

onCloseAppLayoutからpropsとして受け取っている。

\project-root\src\resources\js\Components\Sidebar.jsx
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {}

AppLayoutでは、setSidebarOpenを返すコールバック関数を渡している。

\project-root\src\resources\js\Layouts\AppLayout.jsx
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />

setSidebarOpen(false)により、setIsSidebarOpen(false)となるため、isSidebarOpenfalseになる。

\project-root\src\resources\js\Layouts\AppLayout.jsx
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);

SidebarisOpen propsに対して、それが渡される。これによりisOpenfalseとなる。

\project-root\src\resources\js\Layouts\AppLayout.jsx
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />

Sidebarでは、これを受け取り、三項演算子で開閉状態を表現する。

\project-root\src\resources\js\Components\Sidebar.jsx
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
// ...

  return (
    <>
      <aside
        className= {`
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
        `}
      >
      // ...
      </aside>
    </>
  );
}

propsは複数ファイルにまたがって、処理が流れていくため最初は分かりにくいかもしれません。
前回に引き続き、かなり詳し目に解説しましたが、いかがでしたでしょうか?

次回以降から少しずつ説明を省略していきますので、少しずつ慣れていきましょう!

autoFocusの部分は、マウスを使って操作する分にはそこまで大きな影響を与えるものではないので、気にしなくてOKです。

※余談(ちなみに...)
Reactってルールがいろいろあって難しく感じると思います。
そのうちの一つが、④の部分にあります。

それが、以下のように、コールバック関数を書かずに更新関数を直接渡すような書き方です。

onClose={setSidebarOpen(false)}

これについての解説は今回やろうとすると記事が長くなりすぎてしまうので、割愛しますが、今後余裕があるときにでも解説してみたいと思います!

ですが、気になる方はぜひ調べてみてください!(≧◇≦)

最後に、メニューのリストを追加しましょう。

\project-root\src\resources\js\Components\Sidebar.jsx
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
  return (
    <>
      {/* 本体 */}
      <aside
        role="dialog"
        aria-modal="true"
        className={`
          fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
          transform transition-transform duration-300
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
          flex flex-col
        `}
      >
        {/* ヘッダー(バツ印) */}
        <div className="flex items-center justify-end px-4 h-14">
          <button
            type="button"
            onClick={onClose}
            className="p-2"
            aria-label="メニューを閉じる"
            autoFocus
          >
            <img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
          </button>
        </div>

        {/* リスト */}
        <nav className="p-2">
          <ul className="space-y-1">
            {items.map((it) => (
              <li key={it.label}>
                {it.href ? (
                  <Link
                    href={it.href}
                    method={it.method}
                    as={it.method ? 'button' : 'a'}
                    className="
                      text-expand text-lg w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
                      hover: transition
                      text-[#747D8C] font-medium
                    "
                    onClick={onClose}
                  >
                    <img src={it.icon} alt="" className="h-8 w-8" />
                    {it.label}
                  </Link>
                ) : (
                  <span
                    className="
                      w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
                      text-gray-400 font-medium cursor-not-allowed
                    "
                  >
                    <img
                      src={it.icon}
                      alt=""
                      className={`${it.label === 'ランキング' ? 'h-8 w-8' : 'h-5 w-5'} opacity-40`}
                    />
                    {it.label}
                  </span>
                )}
              </li>
            ))}
          </ul>
        </nav>
      </aside>
    </>
  );
}

ランキングは、未実装によりhrefNullのため、三項演算子で場合分けしていてやや見づらいかもしれませんが、やっていることはシンプルにmap()でメニューのリストを順々に表示しているだけです!

ちなみに、ランキングはクリックできないようにしてあります!

これで、Sidebar.jsxは一旦完成です。(*´Д`)

7. その他追加

ここまででも十分機能するとは思いますが、より良いUI/UXのためにもう一工夫しましょう。

  • 背景のスクロール防止
  • Escキーでサイドバー閉鎖
  • オーバーレイ設定

一工夫と言いつつ、三つもありますw

まずは、AppLayout.jsxuseEffect()を二つ追加しましょう。

まず、背景のスクロール防止です。

\project-root\src\resources\js\Layouts\AppLayout.jsx
  useEffect(() => {
    if (isSidebarOpen) document.body.classList.add('overflow-hidden');
    else document.body.classList.remove('overflow-hidden');
    return () => document.body.classList.remove('overflow-hidden');
  }, [isSidebarOpen]);

useEffectを用いています!
その中で、if-else構文を使って、ページ全体のクラス属性に対して、'overflow-hidden'を、サイドバーが開いているときに追加・閉じているときに除外します。

これによって、サイドバーが開いているときだけ背景のスクロールが禁止されるようになります。

では、これらの処理がいつ起きるのかについて解説していきます。
それを担っているのが、先ほど解説を後回しにした依存配列です!

ユーザーがヘッダーのハンバーガーアイコンをクリックすることでサイドバーが開き、依存配列にあるisSidebarOpentrue変化します。
useEffectは、初回レンダリング時と依存配列に指定されている変数の値が変化するたび発動します。

よって、ユーザーがサイドバーを閉じるボタンを押すと、サイドバーが閉じ、再びisSidebarOpenが変化し、falseになります。
これにより、useEffectが発動します。

こうすることで、サイドバーの開閉に応じて、背景スクロールの可否を変化させることができます。

また、最後のreturn () => document.body.classList.remove('overflow-hidden');ですが、これはクリーンアップ関数と呼ばれるものです。

ちょ~簡単に言えば、このコンポーネントの役割が終わるときに発動するもので、これがないとうまく状態管理をクリーンにできず、メモリに思わぬ負荷がかかることがあります。

この辺の説明も次回以降の余裕がある回でもう少し詳しくお話しできたら良いなと思います。

今日のところは、「そういうのがあるんだ」くらいでも大丈夫です。

次に、Escキーでサイドバー閉鎖ですね。
こちらも、AppLayout.jsxに追加したuseEffectで処理しています。

\project-root\src\resources\js\Layouts\AppLayout.jsx
    useEffect(() => {
    const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

e.keyは押されたキーボードのキーを表しています。
押されたキーボードのキーが'Escape'だったら、setIsSidebarOpen(false)が発動する(つまりサイドバーを閉じる)という処理です。

「あれ?でも依存配列の中身が空っぽだからいつ発動するの?」と思ったかもしれません。

実は、useEffectが発動するのは、依存配列の中にある変数の値が変わったときの他にコンポーネントがレンダリング(厳密には違いますが、まあページが表示されたときと思ってもらえれば分かりやすいかなと思います)されて、マウント(UIが実際にDOMに追加されること)されるときに発動します。

「「レンダリング」、「DOM」、「マウント」、「クリーンアップ関数」とか難しい言葉ばかり使うな!!」って感じっすよねw

難しく考えすぎると、沼にはまるので今日のところは、とりあえず、最初のコンポーネントが読み込まれるときと依存配列内の変数が変化した時に発動すると思えばよいと思います!
機会があれば、この辺の流れについて今度分かりやすくまとめてみたいと思います。

最終的なファイル全体は、以下のようになります!

\project-root\src\resources\js\Layouts\AppLayout.jsx
import { Head, usePage } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import Header from "../Components/Header";
import Sidebar from "../Components/Sidebar";

export default function AppLayout({ children, title }) {
  // ユーザーの認証状態を管理
  const { props } = usePage();
  const isLoggedIn = !!props?.auth?.user;

  // サイドバーの開閉状態を管理
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
  const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);

  // 追加: サイドバーが開いている間は、背景のスクロールを防止
  useEffect(() => {
    if (isSidebarOpen) document.body.classList.add('overflow-hidden');
    else document.body.classList.remove('overflow-hidden');
    return () => document.body.classList.remove('overflow-hidden');
  }, [isSidebarOpen]);

  // 追加: Escキーでサイドバーを閉じる
  useEffect(() => {
    const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return (
    <div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
      <Head title={title} />
      {/* Header 追加: isSidebarOpen */}
      <Header title={title} onOpenSidebar={() => setSidebarOpen(true)} />

      {/* Main Content Area */}
      <main className="flex-1 bg-transparent flex">
        <div
          className="
            mx-auto
            w-full
            max-w-container
            px-[clamp(16px,4vw,32px)]
            py-6
            bg-[#EEF5F9]
            flex-1
          "
        >
          {children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
        </div>
      </main>
      {/* サイドバー */}
      <Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
    </div>
  );
}

最後は、オーバーレイ設定です。
今度は、Sidebar.jsxにつ追加です。
以下のdivで本体を囲みます。

\project-root\src\resources\js\Components\Sidebar.jsx
     {/* オーバーレイ */}
      <div
        onClick={onClose}
        className={`
          fixed inset-0 bg-black/40 transition-opacity duration-300
          ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
        `}
        aria-hidden="true"
      />

ここでやっていることは、実は二つあります。
一つ目は、サイドバーが開いているときにサイドバー以外の部分をクリックするとサイドバーが閉じるようにすることです。

いくらと閉じるためのバツ印ボタンがあるとはいえ、そこまでマウスを動かしてカーソルを合わせるのが面倒という場合もあると思うので、より親切な設計と言えそうです!

onClickイベントで、onCloseが発動するようにします。

ただ、サイドバー以外のどこでも発動するように、サイドバー本体であるasideタグの外側にdivで囲み、fixed inset-0を設定します。

fixedinsetは今後も使う(多分だけどw)と思うので、ぜひ調べてみてください!

https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/position
https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/inset

最終的なファイルは以下の通りです!

\project-root\src\resources\js\Components\Sidebar.jsx
import { Link } from '@inertiajs/react';
import close from '../Assets/icons/sidebar/close.svg';
import home from '../Assets/icons/sidebar/home.svg';
import mypage from '../Assets/icons/sidebar/mypage.svg';
import bookmark from '../Assets/icons/sidebar/bookmark.svg';
import ranking from '../Assets/icons/sidebar/ranking.svg';
import logout from '../Assets/icons/sidebar/logout.svg';
import login from '../Assets/icons/sidebar/login.svg';
import register from '../Assets/icons/sidebar/register.svg';

export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
  // メニュー定義
  const items = isLoggedIn
    ? [
        { label: 'ホーム', href: route('labs.home'), icon: home },
        { label: 'マイページ', href: route('mypage.index'), icon: mypage },
        { label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
        { label: 'ランキング', href: null, icon: ranking },
        { label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
      ]
    : [
        { label: 'ホーム', href: route('labs.home'), icon: home },
        { label: '新規登録', href: route('register'), icon: register },
        { label: 'ログイン', href: route('login'), icon: login },
        { label: 'ランキング', href: null, icon: ranking },
      ];

  return (
    <>
      <style>{`
        @keyframes text-expand {
          0% {
            letter-spacing: 0;
          }
          100% {
            letter-spacing: 0.05em;
          }
        }
        .text-expand:hover {
          animation: text-expand 0.3s ease-out forwards;
        }
      `}</style>
      {/* オーバーレイ */}
      <div
        onClick={onClose}
        className={`
          fixed inset-0 bg-black/40 transition-opacity duration-300
          ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
        `}
        aria-hidden="true"
      />

      {/* 本体 */}
      <aside
        role="dialog"
        aria-modal="true"
        className={`
          fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
          transform transition-transform duration-300
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
          flex flex-col
        `}
      >
        {/* ヘッダー(バツ印) */}
        <div className="flex items-center justify-end px-4 h-14">
          <button
            type="button"
            onClick={onClose}
            className="p-2"
            aria-label="メニューを閉じる"
            autoFocus
          >
            <img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
          </button>
        </div>

        {/* リスト */}
        <nav className="p-2">
          <ul className="space-y-1">
            {items.map(it => (
              <li key={it.label}>
                {it.href ? (
                  <Link
                    href={it.href}
                    method={it.method}
                    as={it.method ? 'button' : 'a'}
                    className="
                      text-expand text-lg w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
                      hover: transition
                      text-[#747D8C] font-medium
                    "
                    onClick={onClose}
                  >
                    <img src={it.icon} alt="" className="h-8 w-8" />
                    {it.label}
                  </Link>
                ) : (
                  <span
                    className="
                      w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
                      text-gray-400 font-medium cursor-not-allowed
                    "
                  >
                    <img
                      src={it.icon}
                      alt=""
                      className={`${it.label === 'ランキング' ? 'h-8 w-8' : 'h-5 w-5'} opacity-40`}
                    />
                    {it.label}
                  </span>
                )}
              </li>
            ))}
          </ul>
        </nav>
      </aside>
    </>
  );
}

8. ログアウト中の動作確認(他ページにAppLayoutを適用)

動作確認をしたいのですが、現状だと共通レイアウトは、マイページにしか適用していません。
マイページはログインしていないとそもそも開けませんね。
よって、動作確認は、ログアウト状態でも開けるページにも共通レイアウトを適用してからにしましょう。

大学一覧ページのコンポーネント内で、AppLayoput.jsxをインポートして、全体を囲みましょう。

\project-root\src\resources\js\Pages\University\Index.jsx
import React, { useState } from 'react';
import { Head, router, Link } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout';

export default function Index({ universities, query }) {
  const [search, setSearch] = useState(query || '');

  const handleSubmit = e => {
    e.preventDefault();
    router.get('/universities', { query: search });
  };

  // ページネーション用の関数
  const goToPage = page => {
    const params = { page };
    if (query) {
      params.query = query;
    }
    router.get('/universities', params);
  };

  const goToPreviousPage = () => {
    if (universities.current_page > 1) {
      goToPage(universities.current_page - 1);
    }
  };

  const goToNextPage = () => {
    if (universities.current_page < universities.last_page) {
      goToPage(universities.current_page + 1);
    }
  };

  const goToFirstPage = () => {
    goToPage(1);
  };

  const goToLastPage = () => {
    goToPage(universities.last_page);
  };

  // ページ番号の配列を生成(現在のページ前後2ページずつ表示)
  const getPageNumbers = () => {
    const current = universities.current_page;
    const last = universities.last_page;
    const pages = [];

    let start = Math.max(1, current - 2);
    let end = Math.min(last, current + 2);

    // 最初の方のページの場合、後ろを多めに表示
    if (current <= 3) {
      end = Math.min(last, 5);
    }

    // 最後の方のページの場合、前を多めに表示
    if (current >= last - 2) {
      start = Math.max(1, last - 4);
    }

    for (let i = start; i <= end; i++) {
      pages.push(i);
    }

    return pages;
  };

  const pageNumbers = getPageNumbers();

  return (
    <div>
      <Head title="大学検索結果" />

      <AppLayout>
        <h1>大学検索</h1>

        <div>
          <input
            type="text"
            value={search}
            onChange={e => setSearch(e.target.value)}
            onKeyPress={e => {
              if (e.key === 'Enter') {
                handleSubmit(e);
              }
            }}
            placeholder="大学名で検索"
          />
          <button onClick={handleSubmit}>検索</button>
        </div>

        {query && (
          <p><strong>{query}</strong>」の検索結果({universities.total}件)
          </p>
        )}

        {universities.data.length === 0 ? (
          <p>該当する大学は見つかりませんでした。</p>
        ) : (
          <ul>
            {universities.data.map(university => (
              <li key={university.id}>
                <Link href={route('faculties.index', university.id)}>{university.name}</Link>
              </li>
            ))}
          </ul>
        )}

        {/* ページネーション */}
        {universities.last_page > 1 && (
          <div>
            <p>
              ページ {universities.current_page} / {universities.last_page}
              (全 {universities.total} 件中 {universities.from} - {universities.to} 件目)
            </p>

            <div>
              {/* 最初のページボタン */}
              <button onClick={goToFirstPage} disabled={universities.current_page === 1}></button>

              {/* 前のページボタン */}
              <button onClick={goToPreviousPage} disabled={universities.current_page === 1}></button>

              {/* 最初のページ番号より前に省略がある場合 */}
              {pageNumbers[0] > 1 && (
                <>
                  <button onClick={() => goToPage(1)}>1</button>
                  {pageNumbers[0] > 2 && <span>...</span>}
                </>
              )}

              {/* ページ番号ボタン */}
              {pageNumbers.map(pageNum => (
                <button
                  key={pageNum}
                  onClick={() => goToPage(pageNum)}
                  disabled={pageNum === universities.current_page}
                >
                  {pageNum}
                </button>
              ))}

              {/* 最後のページ番号より後に省略がある場合 */}
              {pageNumbers[pageNumbers.length - 1] < universities.last_page && (
                <>
                  {pageNumbers[pageNumbers.length - 1] < universities.last_page - 1 && (
                    <span>...</span>
                  )}
                  <button onClick={() => goToPage(universities.last_page)}>
                    {universities.last_page}
                  </button>
                </>
              )}

              {/* 次のページボタン */}
              <button
                onClick={goToNextPage}
                disabled={universities.current_page === universities.last_page}
              ></button>

              {/* 最後のページボタン */}
              <button
                onClick={goToLastPage}
                disabled={universities.current_page === universities.last_page}
              ></button>
            </div>
          </div>
        )}
      </AppLayout>
    </div>
  );
}

ログイン状態・ログアウト状態で確認してみてください。
URLは以下ですね。
http://localhost/universities

ログアウト状態:
image.png

ログイン状態
image.png

9. まとめ・次回予告

お疲れ様でした!
今回は、サイドバーを作りました。

初めてのReact Hooksとして、useStateuseEffectを使用しました。
ですが、これらの使い分けや細かい使い方・仕組みなどについては、記事のボリュームの都合上いったんは割愛しました。

今後、少しずつ慣れていってもらえれば分かるようになっていくと思います...!!

また、propsについては、詳し目に解説を入れました。
少し説明が冗長に感じた方もいらっしゃるかもしれませんね。('_')

次回以降は、少しずつ解説を短くしていくつもりなので、もし今後分からないことが出てきたら、今回の内容に立ち戻って復習してみてください。(*´Д`)

では、また次回お会いしましょう!
コミット・プッシュをお忘れなく!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

参考

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?