Chapter 17

ユーザーメニューを実装する

nino
nino
2021.11.27に更新

まずはログインボタンをクリックするとGoogleログインのUIが立ち上がり、ログインが終わると「ユーザーメニュー」と表示されることを確認してください。これからユーザーメニューをアバターにし、ドロップダウンメニューを実装していきます。

アバターコンポーネントを作成

アバターはいろいろな場所で表示しそうなのでコンポーネント化しておきます。

画像にはNext.jsのImageタグを使うべき?

Next.jsに用意される Image タグを使えば画像を最適化してレンダリングしてくれますが、Googleアカウントのアバター画像のように外部ドメインにホストされている画像には対応していません。 next.config.js ファイルでホストを許可すれば対応可能となりますが、Googleアカウントのアバター画像は複数のサブドメインに分散されているため、想定されるホストをすべて列挙する必要があります。そのホストは今後増える可能性もあるため、半動的なホストに対し Image タグを用いるのは避けた方が良いでしょう。


Imageタグを使えと怒られるが無視

ホストをワイルドカードで指定できるPRが通れば解決しそうです。

components/avatar.tsx
type Props = {
  src: string;
};

const Avatar = ({ src }: Props) => {
  if (src) {
    return <img src={src} className="block rounded-full w-10 h-10" alt="" />;
  } else {
    return <div className="w-10 h-10 bg-gray-400 rounded-full"></div>;
  }
};

export default Avatar;

src があれば画像を表示し、なければグレーの背景を表示しています。

クラス連結の補助関数を作成する

複雑な条件に沿ってクラス名を連結させる場合、クラス連結用の補助関数があると便利です。これはTailwind UI(Tailwind の公式サンプルコード集)でも採用されているテクニックです。以下のファイルを作成しましょう。

lib/class-names.ts
export const classNames = (
  ...classes: (string | undefined | boolean | null)[]
) => classes.filter(Boolean).join(' ');

上記は基本的なJavaScriptの機能を組み合わせたトリックです。まずスプレッド演算子により不特定多数の引数を配列として受け取るようにしています。次に、配列にフィルターをかけ、Falsy(偽)な値を除外しています。最後に配列をスペース区切りで連結させ、最終的なクラス群を文字列として返却しています。

たとえば以下のように使います。

const isActive = false;
<button className={classNames('text-lg', isActive ? 'text-gray-200' : 'text-gray-50', !isActive && 'opacity-30')}

上記の場合、まず text-lg は無条件に適用されます。次に isActive の真偽によって文字色の濃さを切り替えています。最後に isActive が Falsy(偽) の場合、ボタンを薄くしています。

Tailwind CSS における動的なクラスの制御は一般的にこの関数を使って行われます。実際にこれを使ってユーザーメニューを実装していきましょう。

ユーザーメニューを実装する

上記の補助関数とアバターコンポーネントを使ってドロップダウンメニューを実装します。今回のようにユーザー操作で何かが起きるような動的なUIの実装には Tailwind CSS が提供する Headless UI を使います。

今回はメニューコンポーネントのサンプルコードをベースに実装します。

リンクメニュー用に特殊なコンポーネントを作成する

本来Headless UIのドロップダウンはメニュー項目をクリックするとドロップダウンが閉じますが、Next.js の Link タグはその動作に必要なイベントをブロックしてしまうため、 Linkタグをまたいで直接必要なイベントをリンクに付与する必要があります。そのための特殊なリンクメニュー用コンポーネントを作成します。

components/menu-link.tsx
import Link from 'next/link';
import { ReactNode } from 'react';

const MenuLink = ({
  href,
  children,
  ...rest
}: {
  href: string;
  children: ReactNode;
}) => {
  return (
    <Link href={href}>
      <a className="block" {...rest}>
        {children}
      </a>
    </Link>
  );
};

export default MenuLink;

ドロップダウンを実装する

必要なコンポーネントが揃ったのでいよいよドロップダウンを実装します。

components/user-menu.tsx
import { Menu, Transition } from '@headlessui/react';
import { Fragment, ReactNode } from 'react';
import { useAuth } from '../context/auth';
import Avatar from './avatar';
import { CogIcon, LogoutIcon, UserIcon } from '@heroicons/react/solid';
import MenuLink from './menu-link';
import { classNames } from '../lib/class-names';
import { logout } from '../lib/auth';

const links = [
  {
    label: 'マイページ',
    icon: <UserIcon />,
    path: '/mypage',
  },
  {
    label: '設定',
    icon: <CogIcon />,
    path: '/settings',
  },
];

const ListItem = ({
  active,
  icon,
  label,
}: {
  active: boolean;
  icon: ReactNode;
  label: string;
}) => {
  return (
    <span
      className={classNames(
        'flex items-center space-x-2 p-2 rounded text-sm text-left',
        active && 'text-white bg-purple-500'
      )}
    >
      <span
        className={classNames(
          'w-5 h-5',
          active ? 'text-white' : 'text-gray-500'
        )}
      >
        {icon}
      </span>
      <span className="flex-1">{label}</span>
    </span>
  );
};

const UserMenu = () => {
  const user = useAuth();

  if (!user) {
    return null;
  }

  return (
    <Menu as="div" className="relative">
      <Menu.Button className="block">
        <Avatar src={user?.photoURL} />
      </Menu.Button>
      <Transition
        as={Fragment}
        enter="transition ease-out duration-100"
        enterFrom="transform opacity-0 scale-95"
        enterTo="transform opacity-100 scale-100"
        leave="transition ease-in duration-75"
        leaveFrom="transform opacity-100 scale-100"
        leaveTo="transform opacity-0 scale-95"
      >
        <Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
          <div className="p-1 border-b">
            {links.map((link) => (
              <Menu.Item key={link.path}>
                {({ active }) => (
                  <MenuLink href={link.path}>
                    <ListItem
                      icon={link.icon}
                      label={link.label}
                      active={active}
                    />
                  </MenuLink>
                )}
              </Menu.Item>
            ))}
          </div>
          <div className="p-1">
            <Menu.Item>
              {({ active }) => (
                <button className="w-full" onClick={logout}>
                  <ListItem
                    icon={<LogoutIcon />}
                    label="ログアウト"
                    active={active}
                  />
                </button>
              )}
            </Menu.Item>
          </div>
        </Menu.Items>
      </Transition>
    </Menu>
  );
};

export default UserMenu;

まず、メニューは以下の構造になっています。

<Menu className="relative">
  {/* メニュー開閉ボタン */}
  <Menu.Button>メニュー開閉ボタン</Menu.Button>

  {/* アニメーション */}
  <Transition
    as={Fragment}
    // 表示する際のアニメーション
    enter="transition ease-out duration-100"
    enterFrom="transform opacity-0 scale-95"
    enterTo="transform opacity-100 scale-100"
    // 非表示にする際のアニメーション
    leave="transition ease-in duration-75"
    leaveFrom="transform opacity-100 scale-100"
    leaveTo="transform opacity-0 scale-95"
  >
    {/* メニューを囲む */}
    <Menu.Items>
      {/* メニューアイテムを囲む */}
      <Menu.Item>
        {({ active }) => (
          <button>メニューアイテム</button>
        )}
      </Menu.Item>
    </Menu.Items>
  </Transition>
</Menu>

上記の中で active はホバー時、フォーカス時、矢印による選択時などに true となります。これを使って色を変えるなどしてメニューアイテムを強調させます。

Headless UIなのでどのぐらいのサイズ、影でどのぐらいの余白で表示するか、などはすべてこちらで定義しています。アニメーションが不要な場合 <Transition> は不要です。

ユーザーメニューを表示する

作成したユーザーメニューをヘッダーコンポーネントに配置します。

components/header.tsx
import UserMenu from './user-menu';

...

<header className="border-b flex items-center p-4">
  <h1>
    <Link href="/">
      <a className="text-2xl font-logo">iam</a>
    </Link>
  </h1>
  <span className="flex-1"></span>
  {user === null && <Button onClick={login}>ログイン</Button>}
  {/* 追加 */}
  {user && <UserMenu />}
</header>

ログイン後、このように表示されることを確認しましょう。