SCALE — Build Lab
UI部品 · REACT COMPONENT

右クリック コンテキストメニュー

CATEGORYUI部品 TYPEReact Component EFFORT60〜120分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useState, useEffect, ReactNode } from 'react';

type MenuItem = { label: string; action: () => void; danger?: boolean };

export function useContextMenu(items: MenuItem[]) {
  const [pos, setPos] = useState<{ x: number; y: number } | null>(null);

  const open = (e: React.MouseEvent) => { e.preventDefault(); setPos({ x: e.clientX, y: e.clientY }); };
  const close = () => setPos(null);

  useEffect(() => {
    if (!pos) return;
    const onClick = () => close();
    const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
    window.addEventListener('click', onClick);
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('click', onClick);
      window.removeEventListener('keydown', onKey);
    };
  }, [pos]);

  const menu = pos && (
    <div style={{
      position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999,
      background: '#0a0a0c', border: '1px solid rgba(255,255,255,.07)',
      borderRadius: 4, padding: '.25rem 0', minWidth: 160,
      boxShadow: '0 8px 32px rgba(0,0,0,.4)',
    }}>
      {items.map((it, i) => (
        <div key={i} onClick={(e) => { e.stopPropagation(); it.action(); close(); }}
          style={{
            padding: '.5rem 1rem', cursor: 'pointer', fontSize: '.8rem',
            color: it.danger ? '#fb7185' : 'rgba(255,255,255,.85)',
          }}>
          {it.label}
        </div>
      ))}
    </div>
  );

  return { open, menu };
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 任意のダッシュボードに組み込み

右クリック コンテキストメニュー

:LiTarget: 用途

要素を右クリックで表示するメニュー。位置自動調整・Esc で閉じる。

:LiSparkle: 特徴

  • 右クリック検知
  • 位置自動調整
  • Esc で閉じる
  • 画面端で反転

:LiCode: コード(コピペ用)

'use client';
import { useState, useEffect, ReactNode } from 'react';

type MenuItem = { label: string; action: () => void; danger?: boolean };

export function useContextMenu(items: MenuItem[]) {
  const [pos, setPos] = useState<{ x: number; y: number } | null>(null);

  const open = (e: React.MouseEvent) => { e.preventDefault(); setPos({ x: e.clientX, y: e.clientY }); };
  const close = () => setPos(null);

  useEffect(() => {
    if (!pos) return;
    const onClick = () => close();
    const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
    window.addEventListener('click', onClick);
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('click', onClick);
      window.removeEventListener('keydown', onKey);
    };
  }, [pos]);

  const menu = pos && (
    <div style={{
      position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999,
      background: '#0a0a0c', border: '1px solid rgba(255,255,255,.07)',
      borderRadius: 4, padding: '.25rem 0', minWidth: 160,
      boxShadow: '0 8px 32px rgba(0,0,0,.4)',
    }}>
      {items.map((it, i) => (
        <div key={i} onClick={(e) => { e.stopPropagation(); it.action(); close(); }}
          style={{
            padding: '.5rem 1rem', cursor: 'pointer', fontSize: '.8rem',
            color: it.danger ? '#fb7185' : 'rgba(255,255,255,.85)',
          }}>
          {it.label}
        </div>
      ))}
    </div>
  );

  return { open, menu };
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加