ポップオーバー - Vibe Coding Showcase

ポップオーバー

クリックやホバーで追加情報を表示するフローティングパネル。ツールチップやドロップダウンメニューの基盤として使用。

デザインプレビュー

基本的なポップオーバー

配置バリエーション

リッチコンテンツ

カスタムスタイル

ポップオーバーの特徴

自動位置調整

ビューポート境界を検出して最適な位置に配置

柔軟なトリガー

クリック、ホバー、プログラム制御に対応

リッチコンテンツ

テキスト、画像、フォームなど自由なコンテンツを表示

AI活用ガイド

コンテキストメニュー

プロンプト例:

右クリックで表示されるコンテキストメニューを実装してください。階層メニュー、アイコン付きメニュー項目、キーボードショートカット表示、無効化項目、セパレーター、サブメニューのアニメーションを含めてください。

インタラクティブツアー

プロンプト例:

ユーザーガイドツアー機能を作成してください。ステップごとのポップオーバー表示、ハイライト効果、進捗インジケーター、スキップ機能、ステップ間のスムーズな遷移を実装してください。

スマートポップオーバー

プロンプト例:

文脈に応じて自動的に内容を変更するインテリジェントポップオーバーを開発してください。ユーザーの操作履歴、時間帯、デバイス情報に基づいて、表示内容・サイズ・アニメーションを動的に調整してください。

実装コード

ファイルサイズ: 8.7KB TypeScript

コンポーネント実装

import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { BasePopoverProps } from '../types';

interface PopoverProps extends BasePopoverProps {
  children: React.ReactNode;
  content: React.ReactNode;
  className?: string;
}

export const Popover: React.FC<PopoverProps> = ({
  children,
  content,
  trigger = 'click',
  placement = 'bottom',
  open: controlledOpen,
  onOpenChange,
  offset = 8,
  showArrow = true,
  className = '',
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);

  const open = controlledOpen !== undefined ? controlledOpen : isOpen;
  const setOpen = (value: boolean) => {
    if (controlledOpen === undefined) {
      setIsOpen(value);
    }
    onOpenChange?.(value);
  };

  // 位置計算
  const calculatePosition = () => {
    if (!triggerRef.current || !popoverRef.current) return;

    const triggerRect = triggerRef.current.getBoundingClientRect();
    const popoverRect = popoverRef.current.getBoundingClientRect();
    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;

    let top = 0;
    let left = 0;

    // 基本位置計算
    switch (placement) {
      case 'top':
        top = triggerRect.top - popoverRect.height - offset;
        left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
        break;
      case 'bottom':
        top = triggerRect.bottom + offset;
        left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
        break;
      case 'left':
        top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
        left = triggerRect.left - popoverRect.width - offset;
        break;
      case 'right':
        top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
        left = triggerRect.right + offset;
        break;
      case 'topStart':
        top = triggerRect.top - popoverRect.height - offset;
        left = triggerRect.left;
        break;
      case 'topEnd':
        top = triggerRect.top - popoverRect.height - offset;
        left = triggerRect.right - popoverRect.width;
        break;
      case 'bottomStart':
        top = triggerRect.bottom + offset;
        left = triggerRect.left;
        break;
      case 'bottomEnd':
        top = triggerRect.bottom + offset;
        left = triggerRect.right - popoverRect.width;
        break;
      case 'leftStart':
        top = triggerRect.top;
        left = triggerRect.left - popoverRect.width - offset;
        break;
      case 'leftEnd':
        top = triggerRect.bottom - popoverRect.height;
        left = triggerRect.left - popoverRect.width - offset;
        break;
      case 'rightStart':
        top = triggerRect.top;
        left = triggerRect.right + offset;
        break;
      case 'rightEnd':
        top = triggerRect.bottom - popoverRect.height;
        left = triggerRect.right + offset;
        break;
    }

    // ビューポート境界チェック
    if (left < 0) left = 8;
    if (left + popoverRect.width > viewportWidth) {
      left = viewportWidth - popoverRect.width - 8;
    }
    if (top < 0) top = 8;
    if (top + popoverRect.height > viewportHeight) {
      top = viewportHeight - popoverRect.height - 8;
    }

    setPosition({ top, left });
  };

  // トリガー処理
  const handleTriggerClick = () => {
    if (trigger === 'click') {
      setOpen(!open);
    }
  };

  const handleTriggerMouseEnter = () => {
    if (trigger === 'hover') {
      setOpen(true);
    }
  };

  const handleTriggerMouseLeave = () => {
    if (trigger === 'hover') {
      setOpen(false);
    }
  };

  // 外側クリック処理
  useEffect(() => {
    if (!open || trigger !== 'click') return;

    const handleClickOutside = (event: MouseEvent) => {
      if (
        triggerRef.current &&
        !triggerRef.current.contains(event.target as Node) &&
        popoverRef.current &&
        !popoverRef.current.contains(event.target as Node)
      ) {
        setOpen(false);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [open, trigger]);

  // 位置更新
  useEffect(() => {
    if (open) {
      calculatePosition();
      window.addEventListener('resize', calculatePosition);
      window.addEventListener('scroll', calculatePosition);
      return () => {
        window.removeEventListener('resize', calculatePosition);
        window.removeEventListener('scroll', calculatePosition);
      };
    }
  }, [open]);

  // 矢印の位置計算
  const getArrowStyles = () => {
    const arrowSize = 8;
    const styles: React.CSSProperties = {
      position: 'absolute',
      width: 0,
      height: 0,
      borderStyle: 'solid',
    };

    switch (placement) {
      case 'top':
      case 'topStart':
      case 'topEnd':
        styles.bottom = -arrowSize;
        styles.left = '50%';
        styles.transform = 'translateX(-50%)';
        styles.borderWidth = `${arrowSize}px ${arrowSize}px 0 ${arrowSize}px`;
        styles.borderColor = 'white transparent transparent transparent';
        break;
      case 'bottom':
      case 'bottomStart':
      case 'bottomEnd':
        styles.top = -arrowSize;
        styles.left = '50%';
        styles.transform = 'translateX(-50%)';
        styles.borderWidth = `0 ${arrowSize}px ${arrowSize}px ${arrowSize}px`;
        styles.borderColor = 'transparent transparent white transparent';
        break;
      case 'left':
      case 'leftStart':
      case 'leftEnd':
        styles.right = -arrowSize;
        styles.top = '50%';
        styles.transform = 'translateY(-50%)';
        styles.borderWidth = `${arrowSize}px 0 ${arrowSize}px ${arrowSize}px`;
        styles.borderColor = 'transparent transparent transparent white';
        break;
      case 'right':
      case 'rightStart':
      case 'rightEnd':
        styles.left = -arrowSize;
        styles.top = '50%';
        styles.transform = 'translateY(-50%)';
        styles.borderWidth = `${arrowSize}px ${arrowSize}px ${arrowSize}px 0`;
        styles.borderColor = 'transparent white transparent transparent';
        break;
    }

    return styles;
  };

  return (
    <>
      <div
        ref={triggerRef}
        onClick={handleTriggerClick}
        onMouseEnter={handleTriggerMouseEnter}
        onMouseLeave={handleTriggerMouseLeave}
        className="inline-block"
      >
        {children}
      </div>
      {open && typeof window !== 'undefined' && createPortal(
        <div
          ref={popoverRef}
          style={{
            position: 'fixed',
            top: position.top,
            left: position.left,
            zIndex: 50,
          }}
          className={`
            rounded-lg shadow-lg border animate-in fade-in-0 zoom-in-95 duration-200
            ${className ? className : 'bg-white border-gray-200'}
          `}
        >
          {showArrow && <div style={getArrowStyles()} />}
          {content}
        </div>,
        document.body
      )}
    </>
  );
};

// 簡易ツールチップ
export const Tooltip: React.FC<{
  children: React.ReactNode;
  content: string;
  placement?: PopoverProps['placement'];
}> = ({ children, content, placement = 'top' }) => {
  return (
    <Popover
      trigger="hover"
      placement={placement}
      content={
        <div className="px-3 py-1.5 text-sm text-white bg-gray-900 rounded">
          {content}
        </div>
      }
      showArrow={false}
      offset={4}
    >
      {children}
    </Popover>
  );
};

// ポップオーバーのデモコンポーネント
export const PopoverDemo: React.FC = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="space-y-12">
      {/* 基本的なポップオーバー */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">基本的なポップオーバー</h3>
        <div className="flex gap-4">
          <Popover
            content={
              <div className="p-4 max-w-xs">
                <h4 className="font-semibold mb-2">ポップオーバータイトル</h4>
                <p className="text-sm text-gray-600">
                  これはポップオーバーのコンテンツです。
                  クリックで表示/非表示を切り替えます。
                </p>
              </div>
            }
          >
            <button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
              クリックして開く
            </button>
          </Popover>
        </div>
      </div>

      {/* 配置バリエーション */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">配置バリエーション</h3>
        <div className="grid grid-cols-3 gap-4 max-w-3xl mx-auto">
          {(['top', 'bottom', 'left', 'right', 'topStart', 'topEnd', 'bottomStart', 'bottomEnd'] as const).map((placement) => (
            <Popover
              key={placement}
              placement={placement}
              content={
                <div className="p-3">
                  <p className="text-sm">{placement}</p>
                </div>
              }
            >
              <button className="w-full px-3 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300">
                {placement}
              </button>
            </Popover>
          ))}
        </div>
      </div>

      {/* ホバートリガー */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">ホバートリガー</h3>
        <div className="flex gap-4">
          <Popover
            trigger="hover"
            content={
              <div className="p-4 max-w-xs">
                <p className="text-sm">
                  マウスをホバーすると表示されます
                </p>
              </div>
            }
          >
            <span className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg inline-block">
              ホバーしてください
            </span>
          </Popover>
        </div>
      </div>

      {/* リッチコンテンツ */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">リッチコンテンツ</h3>
        <Popover
          content={
            <div className="p-4 w-80">
              <div className="flex items-center mb-3">
                <img
                  src="https://via.placeholder.com/40"
                  alt="Avatar"
                  className="w-10 h-10 rounded-full mr-3"
                />
                <div>
                  <h4 className="font-semibold">ユーザープロフィール</h4>
                  <p className="text-sm text-gray-600">user@example.com</p>
                </div>
              </div>
              <div className="border-t pt-3">
                <button className="w-full px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
                  プロフィールを見る
                </button>
              </div>
            </div>
          }
        >
          <button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
            ユーザー情報
          </button>
        </Popover>
      </div>

      {/* 制御されたポップオーバー */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">制御されたポップオーバー</h3>
        <div className="flex gap-4">
          <Popover
            open={isOpen}
            onOpenChange={setIsOpen}
            content={
              <div className="p-4">
                <p className="text-sm mb-3">外部から制御されています</p>
                <button
                  onClick={() => setIsOpen(false)}
                  className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700"
                >
                  閉じる
                </button>
              </div>
            }
          >
            <button
              onClick={() => setIsOpen(!isOpen)}
              className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
            >
              {isOpen ? '開いています' : '閉じています'}
            </button>
          </Popover>
        </div>
      </div>

      {/* ツールチップ */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">ツールチップ</h3>
        <div className="flex gap-4">
          <Tooltip content="これはツールチップです">
            <button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
              ホバーしてください
            </button>
          </Tooltip>
          <Tooltip content="右側に表示" placement="right">
            <button className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
              右側
            </button>
          </Tooltip>
        </div>
      </div>
    </div>
  );
};

export default Popover;

使用例

// 基本的な使用例
import { Popover, Tooltip } from './popover';

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      {/* 基本的なポップオーバー */}
      <Popover
        content={
          <div className="p-4">
            <h4 className="font-semibold mb-2">ポップオーバー</h4>
            <p>追加情報をここに表示します</p>
          </div>
        }
      >
        <button>クリックして開く</button>
      </Popover>

      {/* 配置の指定 */}
      <Popover
        placement="top"
        content={<div className="p-3">上部に表示</div>}
      >
        <button>上部配置</button>
      </Popover>

      {/* ホバートリガー */}
      <Popover
        trigger="hover"
        content={<div className="p-3">ホバー時に表示</div>}
      >
        <span>ホバーしてください</span>
      </Popover>

      {/* 制御されたポップオーバー */}
      <Popover
        open={isOpen}
        onOpenChange={setIsOpen}
        content={
          <div className="p-4">
            <p>外部から制御</p>
            <button onClick={() => setIsOpen(false)}>
              閉じる
            </button>
          </div>
        }
      >
        <button onClick={() => setIsOpen(!isOpen)}>
          トグル
        </button>
      </Popover>

      {/* リッチコンテンツ */}
      <Popover
        content={
          <div className="p-4 w-80">
            <img src="..." alt="..." className="w-full mb-3" />
            <h4 className="font-bold">タイトル</h4>
            <p className="text-sm text-gray-600">
              画像やフォームなども含められます
            </p>
            <button className="mt-3 w-full">
              アクション
            </button>
          </div>
        }
      >
        <button>リッチコンテンツ</button>
      </Popover>

      {/* ツールチップ */}
      <Tooltip content="これはヒントです">
        <button>?</button>
      </Tooltip>

      {/* カスタムオフセット */}
      <Popover
        content={<div className="p-3">離れて表示</div>}
        offset={20}
        showArrow={false}
      >
        <button>カスタムオフセット</button>
      </Popover>
    </div>
  );
}