Files
lynkeduppro-crm/vendor/photo-gallery-sdk/src/components/ContextMenu.tsx
T

172 lines
4.9 KiB
TypeScript

'use client';
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Icon, type IconName } from '../icons';
export interface MenuItem {
type?: 'item';
label: string;
icon?: IconName;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
checked?: boolean;
}
export interface MenuSeparator {
type: 'separator';
}
export interface MenuLabel {
type: 'label';
label: string;
}
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel;
interface MenuState {
x: number;
y: number;
entries: MenuEntry[];
}
// Module-level emitter — typically one gallery is interactive at a time.
let current: MenuState | null = null;
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
export function openContextMenu(x: number, y: number, entries: MenuEntry[]) {
current = { x, y, entries };
emit();
}
export function closeContextMenu() {
if (current) {
current = null;
emit();
}
}
/** Whether a context menu is open (it owns Escape while it is). */
export function isContextMenuOpen(): boolean {
return current !== null;
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
function getSnapshot() {
return current;
}
/** Renders the active context menu (mounted once near the gallery root). */
export function ContextMenuHost() {
const state = useSyncExternalStore(subscribe, getSnapshot, () => null);
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
useLayoutEffect(() => {
if (!state || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
const pad = 8;
const left = Math.min(state.x, window.innerWidth - rect.width - pad);
const top = Math.min(state.y, window.innerHeight - rect.height - pad);
setPos({ left: Math.max(pad, left), top: Math.max(pad, top) });
// Move keyboard focus to the first actionable item when the menu opens.
ref.current.querySelector<HTMLButtonElement>('button.apg-menu__item:not([disabled])')?.focus();
}, [state]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const items = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>('button.apg-menu__item:not([disabled])') ?? [],
);
if (items.length === 0) return;
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
if (e.key === 'ArrowDown') {
e.preventDefault();
items[(idx + 1) % items.length]!.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items[(idx - 1 + items.length) % items.length]!.focus();
} else if (e.key === 'Home') {
e.preventDefault();
items[0]!.focus();
} else if (e.key === 'End') {
e.preventDefault();
items[items.length - 1]!.focus();
}
};
useEffect(() => {
if (!state) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) closeContextMenu();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
const onScroll = () => closeContextMenu();
window.addEventListener('mousedown', onDown, true);
window.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('mousedown', onDown, true);
window.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
};
}, [state]);
if (!state) return null;
return (
<div
ref={ref}
className="apg-menu"
role="menu"
style={{ left: pos.left, top: pos.top }}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={onMenuKeyDown}
>
{state.entries.map((entry, i) => {
if (entry.type === 'separator') return <div key={i} className="apg-menu__sep" />;
if (entry.type === 'label')
return (
<div key={i} className="apg-menu__label">
{entry.label}
</div>
);
return (
<button
key={i}
type="button"
role="menuitem"
className={['apg-menu__item', entry.danger ? 'apg-menu__item--danger' : '']
.filter(Boolean)
.join(' ')}
disabled={entry.disabled}
onClick={() => {
closeContextMenu();
entry.onClick();
}}
>
{entry.icon ? (
<span className="apg-menu__icon">
<Icon name={entry.icon} size={16} />
</span>
) : (
<span className="apg-menu__icon" />
)}
<span>{entry.label}</span>
{entry.checked ? (
<span className="apg-menu__check">
<Icon name="check" size={15} />
</span>
) : null}
</button>
);
})}
</div>
);
}