From 28b6d649360520053b79b17da42fc934282c5f79 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 01:14:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(P7.T2):=20ComposerActionsMenu=20popover=20?= =?UTF-8?q?(Bild/Datei=20+=20Umfrage=20+=20Aktivit=C3=A4ten=20group)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ComposerActionsMenu.tsx | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 apps/desktop/src/components/ComposerActionsMenu.tsx diff --git a/apps/desktop/src/components/ComposerActionsMenu.tsx b/apps/desktop/src/components/ComposerActionsMenu.tsx new file mode 100644 index 0000000..a592641 --- /dev/null +++ b/apps/desktop/src/components/ComposerActionsMenu.tsx @@ -0,0 +1,250 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { MonitorShareIcon, PollIcon } from './icons'; + +interface Props { + anchorRef: React.RefObject; + 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(null); + const firstItemRef = useRef(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>; + 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) => { + const focusable = menuRef.current?.querySelectorAll( + '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 ( +
+ {topItems.map((item, idx) => { + const Icon = item.Icon; + const isFirst = idx === 0; + return ( + + ); + })} + + ); +} + +// --- Inline icons not in the central icons module ---------------------- + +function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} + +function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + ); +} + +function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + ); +}