モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
クリックやホバーで追加情報を表示するフローティングパネル。ツールチップやドロップダウンメニューの基盤として使用。
ビューポート境界を検出して最適な位置に配置
クリック、ホバー、プログラム制御に対応
テキスト、画像、フォームなど自由なコンテンツを表示
プロンプト例:
右クリックで表示されるコンテキストメニューを実装してください。階層メニュー、アイコン付きメニュー項目、キーボードショートカット表示、無効化項目、セパレーター、サブメニューのアニメーションを含めてください。
プロンプト例:
ユーザーガイドツアー機能を作成してください。ステップごとのポップオーバー表示、ハイライト効果、進捗インジケーター、スキップ機能、ステップ間のスムーズな遷移を実装してください。
プロンプト例:
文脈に応じて自動的に内容を変更するインテリジェントポップオーバーを開発してください。ユーザーの操作履歴、時間帯、デバイス情報に基づいて、表示内容・サイズ・アニメーションを動的に調整してください。
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>
);
}