diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 191a72af..9c4c2d4e 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -1,10 +1,13 @@ import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type RefObject +} from 'react' type SessionActionMenuProps = { isOpen: boolean @@ -13,6 +16,9 @@ type SessionActionMenuProps = { onRename: () => void onArchive: () => void onDelete: () => void + anchorRef?: RefObject + align?: 'start' | 'end' + menuId?: string } function EditIcon(props: { className?: string }) { @@ -79,8 +85,29 @@ function TrashIcon(props: { className?: string }) { ) } +type MenuPosition = { + top: number + left: number + transformOrigin: string +} + export function SessionActionMenu(props: SessionActionMenuProps) { - const { isOpen, onClose, sessionActive, onRename, onArchive, onDelete } = props + const { + isOpen, + onClose, + sessionActive, + onRename, + onArchive, + onDelete, + anchorRef, + align = 'end', + menuId + } = props + const menuRef = useRef(null) + const [menuPosition, setMenuPosition] = useState(null) + const internalId = useId() + const resolvedMenuId = menuId ?? `session-action-menu-${internalId}` + const headingId = `${resolvedMenuId}-heading` const handleRename = () => { onClose() @@ -97,43 +124,158 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onDelete() } - return ( - !open && onClose()}> - - - Session Actions - -
- + const updatePosition = useCallback(() => { + const menuEl = menuRef.current + if (!menuEl) return - {sessionActive ? ( - - ) : ( - - )} -
-
-
+ const menuRect = menuEl.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const padding = 8 + const gap = 8 + + let top = (viewportHeight - menuRect.height) / 2 + let left = (viewportWidth - menuRect.width) / 2 + let transformOrigin = 'top center' + + const anchorEl = anchorRef?.current + if (anchorEl) { + const anchorRect = anchorEl.getBoundingClientRect() + const spaceBelow = viewportHeight - anchorRect.bottom + const spaceAbove = anchorRect.top + const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow + + top = openAbove ? anchorRect.top - menuRect.height - gap : anchorRect.bottom + gap + if (align === 'start') { + left = anchorRect.left + transformOrigin = openAbove ? 'bottom left' : 'top left' + } else { + left = anchorRect.right - menuRect.width + transformOrigin = openAbove ? 'bottom right' : 'top right' + } + } + + top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding) + left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding) + + setMenuPosition({ top, left, transformOrigin }) + }, [align, anchorRef]) + + useLayoutEffect(() => { + if (!isOpen) return + updatePosition() + }, [isOpen, updatePosition]) + + useEffect(() => { + if (!isOpen) { + setMenuPosition(null) + return + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node + if (menuRef.current?.contains(target)) return + if (anchorRef?.current?.contains(target)) return + onClose() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + const handleReflow = () => { + updatePosition() + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('resize', handleReflow) + window.addEventListener('scroll', handleReflow, true) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('resize', handleReflow) + window.removeEventListener('scroll', handleReflow, true) + } + }, [anchorRef, isOpen, onClose, updatePosition]) + + useEffect(() => { + if (!isOpen) return + + const frame = window.requestAnimationFrame(() => { + const firstItem = menuRef.current?.querySelector('[role="menuitem"]') + firstItem?.focus() + }) + + return () => window.cancelAnimationFrame(frame) + }, [isOpen]) + + if (!isOpen) return null + + const menuStyle: CSSProperties | undefined = menuPosition + ? { + top: menuPosition.top, + left: menuPosition.left, + transformOrigin: menuPosition.transformOrigin + } + : undefined + + const baseItemClassName = + 'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]' + + return ( +
+
+ Session actions +
+ +
) } diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index e111b69a..e81a1078 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useId, useMemo, useRef, useState } from 'react' import type { Session } from '@/types/api' import type { ApiClient } from '@/api/client' import { isTelegramApp } from '@/hooks/useTelegram' @@ -70,6 +70,8 @@ export function SessionHeader(props: { const worktreeBranch = session.metadata?.worktree?.branch const [menuOpen, setMenuOpen] = useState(false) + const menuId = useId() + const menuAnchorRef = useRef(null) const [renameOpen, setRenameOpen] = useState(false) const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) @@ -139,7 +141,11 @@ export function SessionHeader(props: {