28b6d64936
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
7.5 KiB
TypeScript
251 lines
7.5 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { MonitorShareIcon, PollIcon } from './icons';
|
|
|
|
interface Props {
|
|
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onAttachFile: () => void;
|
|
onCreatePoll: () => void;
|
|
onCreateWhiteboard: () => void;
|
|
onStartWatchTogether: () => void;
|
|
onStartGame: () => void;
|
|
canStartGame?: boolean;
|
|
}
|
|
|
|
export function ComposerActionsMenu({
|
|
anchorRef,
|
|
open,
|
|
onClose,
|
|
onAttachFile,
|
|
onCreatePoll,
|
|
onCreateWhiteboard,
|
|
onStartWatchTogether,
|
|
onStartGame,
|
|
canStartGame = true,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const firstItemRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
firstItemRef.current?.focus();
|
|
const onDocClick = (e: MouseEvent) => {
|
|
const target = e.target as Node | null;
|
|
if (!target) return;
|
|
if (menuRef.current?.contains(target)) return;
|
|
if (anchorRef.current?.contains(target)) return;
|
|
onClose();
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('mousedown', onDocClick);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDocClick);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open, onClose, anchorRef]);
|
|
|
|
if (!open) return null;
|
|
|
|
// Each item: closes the menu, then runs the action.
|
|
const items: Array<{
|
|
key: string;
|
|
label: string;
|
|
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
|
action: () => void;
|
|
disabled?: boolean;
|
|
disabledTitle?: string;
|
|
section: 'top' | 'activities';
|
|
}> = [
|
|
{
|
|
key: 'attach',
|
|
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
|
|
Icon: PaperclipIcon,
|
|
action: onAttachFile,
|
|
section: 'top',
|
|
},
|
|
{
|
|
key: 'poll',
|
|
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
|
|
Icon: PollIcon,
|
|
action: onCreatePoll,
|
|
section: 'top',
|
|
},
|
|
{
|
|
key: 'whiteboard',
|
|
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
|
|
Icon: MonitorShareIcon,
|
|
action: onCreateWhiteboard,
|
|
section: 'activities',
|
|
},
|
|
{
|
|
key: 'watch',
|
|
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
|
|
Icon: PlayBoxIcon,
|
|
action: onStartWatchTogether,
|
|
section: 'activities',
|
|
},
|
|
{
|
|
key: 'game',
|
|
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
|
|
Icon: GameIcon,
|
|
action: onStartGame,
|
|
disabled: !canStartGame,
|
|
disabledTitle: t('app:composer.menu.game_dm_only', {
|
|
defaultValue: 'Nur in 1:1-Chats',
|
|
}),
|
|
section: 'activities',
|
|
},
|
|
];
|
|
|
|
const handleItemClick = (item: (typeof items)[number]) => {
|
|
if (item.disabled) return;
|
|
onClose();
|
|
item.action();
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
'button[role="menuitem"]:not([disabled])',
|
|
);
|
|
if (!focusable || focusable.length === 0) return;
|
|
const list = Array.from(focusable);
|
|
const idx = list.findIndex((el) => el === document.activeElement);
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
list[(idx + 1) % list.length]?.focus();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
list[(idx - 1 + list.length) % list.length]?.focus();
|
|
}
|
|
};
|
|
|
|
const topItems = items.filter((i) => i.section === 'top');
|
|
const activityItems = items.filter((i) => i.section === 'activities');
|
|
|
|
return (
|
|
<div
|
|
ref={menuRef}
|
|
role="menu"
|
|
onKeyDown={handleKeyDown}
|
|
// Positioned absolutely above the anchor; the wrapping parent (the
|
|
// composer) must be `position: relative` for this to anchor correctly.
|
|
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
|
>
|
|
{topItems.map((item, idx) => {
|
|
const Icon = item.Icon;
|
|
const isFirst = idx === 0;
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
ref={isFirst ? firstItemRef : undefined}
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => handleItemClick(item)}
|
|
disabled={item.disabled}
|
|
title={item.disabled ? item.disabledTitle : undefined}
|
|
className={
|
|
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
|
(item.disabled
|
|
? 'cursor-not-allowed opacity-50'
|
|
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
|
}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
<div
|
|
role="separator"
|
|
className="border-t border-line/60"
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
|
|
</div>
|
|
{activityItems.map((item) => {
|
|
const Icon = item.Icon;
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => handleItemClick(item)}
|
|
disabled={item.disabled}
|
|
title={item.disabled ? item.disabledTitle : undefined}
|
|
className={
|
|
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
|
(item.disabled
|
|
? 'cursor-not-allowed opacity-50'
|
|
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
|
}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Inline icons not in the central icons module ----------------------
|
|
|
|
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
{...props}
|
|
>
|
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
{...props}
|
|
>
|
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
{...props}
|
|
>
|
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
|
</svg>
|
|
);
|
|
}
|