Radix UI Popoverで簡単実装！ボタンクリックで小ウィンドウを表示する方法（ポップオーバーの作り方）

# Radix UI Popoverで簡単に高機能なポップオーバーを実装

私は`Next.Js`と`tailwindcss`を使用し、フロントエンド開発を行っている初心者です。
Web開発を始めて日が浅い私は、ボタンクリックでその要素の近くに小さなウィンドウを表示させることに、四苦八苦しておりました。簡単に実装できないかと、いくつかの方法を調べてみました。
今回は、その中からRadix UI の Popover コンポーネントの使い方をまとめます。
「ポップオーバー」とは、ウェブページで特定のボタンやリンクをクリックしたときに、その近くに現れる小さなウィンドウのことです。Radix UIのPopoverコンポーネントは、そんなポップオーバーを簡単に実装するためのツールです。

## 目次

1. [はじめに](#はじめに)
2. [必要なパッケージ](#必要なパッケージ)
3. [基本的な使用例](#基本的な使用例)
4. [各コンポーネントの詳細](#各コンポーネントの詳細)
5. [まとめ](#まとめ)


## はじめに

Radix UIは、アクセシビリティと柔軟性に優れたReactコンポーネントライブラリです。
その中でも、Radix UI Popoverは、Webアプリケーションで追加情報や対話的な要素を表示するための高機能なコンポーネントです。基本的な使い方は、以下の4つのメインコンポーネントで構成されます。

1. `Popover.Root`: ポップオーバー全体の状態を管理
2. `Popover.Trigger`: ポップオーバーを開くボタンやエレメント
3. `Popover.Portal`: ポップオーバーのレンダリング位置を制御
4. `Popover.Content`: ポップオーバー内に表示するコンテンツ

## 必要なパッケージ

Radix UI Popoverをインストールします。


### npm
```bash
npm install @radix-ui/react-popover
```

### yarn
```bash
yarn add @radix-ui/react-popover
```

## 基本的な使用例
最もシンプルなポップオーバーの実装方法を紹介します。以下の例は、ヘルプアイコン（❓）をクリックすると、プロフィール更新に関するガイドラインを表示するポップオーバーです。

```typescript
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@radix-ui/react-popover';

import React from 'react';

// プロフィール更新のヘルプガイダンスを表示するコンポーネント
function UserProfileHelp() {
  return (
    <Popover>
      <Popover.Trigger className="help-icon">
        ❓ {/* ヘルプアイコン */}
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Content>
          {/* プロフィール更新時の注意事項 */}
          プロフィール情報の更新には、
          以下の点に注意してください：
          - 本名は正確に入力
          - プロフィール画像は clear な画像を選択
          - 連絡先情報は最新の情報に更新
        </Popover.Content>
      </Popover.Portal>
    </Popover>
  );
}
```

## 各コンポーネントの詳細
### Popover.Root（Popover）

`Popover.Root`（または単に`Popover` ※省略可能）は、すべてのPopoverコンポーネントの状態管理を担います。開閉状態、フォーカス管理、アクセシビリティ属性の制御を自動的に行います。
全体を`Popover.Root`でラップ（囲む）ことで、これらの機能が有効になります。

```typescript
<Popover>
  {/* Triggerと連携して状態を管理 */}
  <Popover.Trigger>開く</Popover.Trigger>
  <Popover.Content>内容</Popover.Content>
</Popover>
```

#### Popover.Root のプロパティ
1. `open?: boolean`
   - ポップオーバーの開閉状態を外部から制御
   - `true`: 強制的に開く
   - `false`: 強制的に閉じる
   - デフォルト: 未設定（コンポーネント内部で状態管理）
   - 指定しない場合、Radix UIが自動的に状態を管理
   - 値を指定すると、ポップオーバーの開閉を完全に制御可能

2. `defaultOpen?: boolean`
   - 初期状態を設定
   - `true`: 最初から開いた状態
   - `false`: 最初は閉じた状態（デフォルト）

3. `onOpenChange?: (open: boolean) => void`
   - ポップオーバーの状態が変わったときに呼ばれる関数
   - 状態変更を追跡・処理できる

#### 状態管理の具体例
状態管理の重要性を理解できる例として、フォーム入力時のヘルプガイダンスシステムを紹介します。
ユーザーがメールアドレスを入力する際に、適切なガイダンスを提供する例です。

```typescript
function StateControlPopoverExample() {
  // メールアドレス入力フォームの状態管理
  // isOpen: ポップオーバーの表示/非表示を制御
  // formData: フォームの入力データを管理
  const [isOpen, setIsOpen] = React.useState(false);
  const [formData, setFormData] = React.useState({
    email: '' // メールアドレスの入力値
  });

  // 入力フィールドにフォーカスした時の処理
  // ユーザーが入力を開始する前にガイダンスを表示
  const handleInputFocus = () => {
    setIsOpen(true);  // ポップオーバーを表示
  };

  // メールアドレス入力時の処理
  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormData({
      ...formData,
      email: e.target.value
    });
  };

  return (
    <Popover.Root 
      open={isOpen}
      onOpenChange={setIsOpen}
    >
      {/* メールアドレス入力フィールド */}
      <input 
        onFocus={handleInputFocus}
        onChange={handleEmailChange}
        value={formData.email}
        placeholder="メールアドレスを入力"
        className="border p-2 rounded"
      />

      {/* ヘルプボタン - 常時表示されるトリガー */}
      <Popover.Trigger asChild>
        <button className="ml-2 text-blue-500">
          詳細情報
        </button>
      </Popover.Trigger>

      {/* ポップオーバーの内容 */}
      <Popover.Portal>
        <Popover.Content className="bg-white p-4 rounded shadow-lg">
          {/* ユーザーへのガイダンス情報 */}
          <h3 className="font-bold mb-2">メールアドレスの入力について</h3>
          <ul className="list-disc pl-4">
            <li>example@domain.com の形式で入力</li>
            <li>会社のメールアドレスを推奨</li>
            <li>個人情報保護のため、安全なアドレスを使用</li>
          </ul>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

##### このコードの主なポイント

- フォーム入力値（formData）とポップオーバーの表示状態（isOpen）を連動させることで、ユーザー操作に応じた適切なガイダンスを提供
- handleInputFocus によるフォーカス時の自動ガイダンス表示
- メールアドレスの入力値を状態として管理し、将来的なバリデーションなどの機能拡張に対応可能
- ユーザーフレンドリーなUIとして、入力フィールドとヘルプボタンを組み合わせた設計

状態管理を適切に行うことで、ユーザー体験の向上と将来的な機能拡張の両方に対応できる設計となっています。

### Popover.TriggerとasChildプロパティ
`Popover.Trigger`は、ポップオーバーを起動するためのボタンやコンポーネントを定義します。
`asChild`プロパティを使用すると、既存のコンポーネントに簡単にポップオーバー機能を追加できます。
要するに、クリックしたら開くボタンです。

```typescript
<Popover.Trigger asChild>
  {/* 独自のボタンやコンポーネントを使用可能 */}
  <button className="custom-button">
    カスタムボタン
  </button>
</Popover.Trigger>
```

`asChild`を使用することで：

- デフォルトのスタイリングを上書き可能
- 任意のコンポーネントにポップオーバー機能を追加
- より柔軟なUI設計が可能

#### Popover.Trigger のプロパティ

`asChild?: boolean`
   - デフォルト: `false`
   - `false`: Radix UIが標準のトリガーボタンを作成
   - `true`: 自分で用意したコンポーネント（ボタンなど）をトリガーとして使用可能

### Popover.Portal
`Popover.Portal`は、ポップオーバーのDOMツリーの制約から解放し、より柔軟なUI実装を可能にします。
しかし、通常は指定する必要はなく、デフォルトの動作で十分な場合が多いです。

`Popover.Portal`を使用しない場合と使用した場合の違いを具体的に説明します。

#### Portal未使用時の問題点

多くの開発者が直面する典型的な課題は、親要素のスタイルや制約によってポップオーバーの表示が制限されることです。

```typescript
function WithoutPortal() {
  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      <Popover.Root>
        <Popover.Trigger>トリガー</Popover.Trigger>
        <Popover.Content>
          {/* 親要素のスタイルや制約に影響を受ける */}
          このコンテンツは、親要素の`overflow: hidden`や
          `position: relative`の影響で正しく表示されない可能性があります。
        </Popover.Content>
      </Popover.Root>
    </div>
  );
}
```

#### Portal使用時の解決策

`Popover.Portal`を使用することで、これらの制約から完全に解放されます。

```typescript
function WithPortal() {
  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      <Popover.Root>
        <Popover.Trigger>トリガー</Popover.Trigger>
        <Popover.Portal>
          <Popover.Content>
            {/* DOMツリーの制約から解放され、
                画面上の任意の位置に表示可能 */}
            親要素のスタイルに関係なく、
            常に正しく表示されます。
          </Popover.Content>
        </Popover.Portal>
      </Popover.Root>
    </div>
  );
}
```

##### Portalを使用するメリット

- 要素のスタイル制約から解放
- z-indexの問題を回避
- 柔軟な配置が可能
- アプリケーションのレイアウトに影響を与えない

#### Popover.Portal のプロパティ

 `container?: HTMLElement`
  - デフォルト: `document.body`（画面全体）
  - 任意のHTML要素を指定して、ポップオーバーを特定の場所にレンダリング可能
  - 指定しない場合は、画面の一番下（body）に自動的に表示

### Popover.Content

`Popover.Content`は、ポップオーバー内に表示するコンテンツとその振る舞いを定義します。ユーザーが触れたときの動き、見た目の変化、表示位置など、細かいところまでカスタマイズできます。
要するに、ポップオーバー内に表示する中身の部分です。

```typescript
<Popover.Content
  className="
    bg-white 
    shadow-lg 
    rounded-md 
    p-4
  "
>
  詳細コンテンツ
</Popover.Content>
```

#### Popover.Content のプロパティ

1. `side?: 'top' | 'right' | 'bottom' | 'left'`
   - ポップオーバーがトリガー要素のどの位置に表示されるか
   - デフォルトは `'bottom'`
   - 例: `side="right"` → トリガー要素の右側にポップオーバーを表示
   
2. `sideOffset?: number`
   - ポップオーバーを、指定した位置 (`'side'`) からどれだけ離すかを設定
   - デフォルトは `5`(ピクセル単位)
   - 例: `sideOffset={10}` → トリガーから10px下に表示（マイナスで上に表示）

3. `align?: 'start' | 'center' | 'end'`
   - トリガー要素に対して、指定した方向 (`'side'`) 内での配置
   - デフォルトは `'center'`
   - 例: `align="start"` → トリガー要素の左端に揃えて配置

4. `alignOffset?: number`
   - 配置方向 (`'align'`) をさらに細かく調整するためのオフセット値
   - デフォルトは `0`(ピクセル単位)
   - 例: `alignOffset={10}` → 配置を10px右にずらす（マイナスで左にずらす）

5. `avoidCollisions?: boolean`
   - 画面内に収まるように自動調整
   - デフォルトは `true`
   - 例: `avoidCollisions={false}` → 自動調整を無効化

6. `collisionPadding?: number`
   - 画面端との最小距離(余白)
   - デフォルトは `0`(ピクセル単位)
   - 例: `collisionPadding={10}` → 画面端との距離を最低10px確保

#### data-state属性の活用

Radix UIは自動的に`data-state`属性を追加し、コンポーネントの状態を制御できます。そのため、Tailwind CSSなどのユーティリティクラスを利用して、状態に応じた動的なスタイリングが可能になります。

##### data-state の値

- `open`: ポップオーバーが開いている状態
- `closed`: ポップオーバーが閉じている状態

##### Tailwindでのdata-state活用例

```typescript
<Popover.Content
  className="
    bg-white 
    shadow-lg 
    rounded-md 
    p-4
    data-[state=open]:bg-gray-100　// Popoverが開いている時は、薄いグレー
  "
>
  詳細コンテンツ
</Popover.Content>
```

## まとめ

Radix UI Popoverは、アクセシビリティと柔軟性に優れたポップオーバーコンポーネントです。`asChild`と`data-state`属性を理解すれば、さらに高度な実装が可能になります。

もし記事の内容に間違いがあれば、コメントでご指摘いただけますと幸いです。また、より良い方法や代替手段をご存知の方がいらっしゃいましたら、ぜひ共有していただければと存じます。
Radix UI Popoverについて、皆様の経験や工夫、カスタマイズ事例をぜひコメントでお聞かせください。