diff --git a/web/src/components/ui/Menu.tsx b/web/src/components/ui/Menu.tsx index 8a05fe8..bfa1301 100644 --- a/web/src/components/ui/Menu.tsx +++ b/web/src/components/ui/Menu.tsx @@ -7,6 +7,8 @@ import { type RefObject, } from 'react'; +import { useAnchoredPosition } from '@/lib/useAnchoredPosition'; + interface MenuProps { open: boolean; /** Доступное имя меню (озвучивается скринридером). */ @@ -51,6 +53,14 @@ export function Menu({ className = '', }: MenuProps) { const panelRef = useRef(null); + const fallbackAnchor = useRef(null); + const position = useAnchoredPosition({ + open, + anchorRef: anchorRef ?? fallbackAnchor, + panelRef, + placement, + align, + }); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; @@ -153,11 +163,19 @@ export function Menu({ role="menu" aria-label={label} onKeyDown={onKeyDown} - className={`absolute z-40 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${ - placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2' - } ${align === 'right' ? 'right-0' : 'left-0'} ${className}`} + data-testid="menu-panel" + style={{ + position: 'fixed', + left: position?.left ?? -9999, + top: position?.top ?? -9999, + maxHeight: position?.maxHeight, + visibility: position === null ? 'hidden' : 'visible', + }} + className={`z-40 flex min-w-56 flex-col overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 shadow-[var(--shadow-3)] ${ + position?.placement === 'bottom' ? 'gl-popover-down' : 'gl-popover' + } ${className}`} > - {children} +
{children}
); } diff --git a/web/src/components/ui/Popover.tsx b/web/src/components/ui/Popover.tsx index d5e4e9b..b2d52ff 100644 --- a/web/src/components/ui/Popover.tsx +++ b/web/src/components/ui/Popover.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, type ReactNode, type RefObject } from 'react'; +import { useAnchoredPosition } from '@/lib/useAnchoredPosition'; + interface PopoverProps { open: boolean; /** Доступное имя панели (озвучивается скринридером). */ @@ -8,6 +10,7 @@ interface PopoverProps { children: ReactNode; /** Элемент-якорь: клик по нему не считается кликом «вне панели». */ anchorRef?: RefObject; + /** Куда раскрывается панель: вверх (по умолчанию) или вниз. */ placement?: 'top' | 'bottom'; align?: 'left' | 'right'; @@ -32,6 +35,14 @@ export function Popover({ className = '', }: PopoverProps) { const panelRef = useRef(null); + const fallbackAnchor = useRef(null); + const position = useAnchoredPosition({ + open, + anchorRef: anchorRef ?? fallbackAnchor, + panelRef, + placement, + align, + }); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; @@ -88,11 +99,21 @@ export function Popover({ ref={panelRef} role="dialog" aria-label={label} - className={`absolute z-40 w-64 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 shadow-[var(--shadow-3)] ${ - placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2' - } ${align === 'right' ? 'right-0' : 'left-0'} ${className}`} + data-testid="popover-panel" + style={{ + position: 'fixed', + left: position?.left ?? -9999, + top: position?.top ?? -9999, + maxHeight: position?.maxHeight, + visibility: position === null ? 'hidden' : 'visible', + }} + className={`z-40 flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 shadow-[var(--shadow-3)] ${ + position?.placement === 'bottom' ? 'gl-popover-down' : 'gl-popover' + } w-64 ${className}`} > - {children} + {/* Содержимое прокручивается: иначе при нехватке места не дотянуться + до слайдера громкости (жалоба пользователя). */} +
{children}
); } diff --git a/web/src/lib/useAnchoredPosition.ts b/web/src/lib/useAnchoredPosition.ts new file mode 100644 index 0000000..c5ec984 --- /dev/null +++ b/web/src/lib/useAnchoredPosition.ts @@ -0,0 +1,90 @@ +import { useCallback, useLayoutEffect, useState, type RefObject } from 'react'; + +/** Отступ панели от краёв окна, чтобы тень и рамка не обрезались. */ +export const ANCHOR_MARGIN = 8; + +export interface AnchoredPosition { + left: number; + top: number; + maxHeight: number; + placement: 'top' | 'bottom'; +} + +export interface AnchoredPositionOptions { + open: boolean; + anchorRef: RefObject; + panelRef: RefObject; + /** Предпочтительное направление раскрытия. */ + placement: 'top' | 'bottom'; + align: 'left' | 'right'; +} + +/** + * Позиционирование всплывающей панели в координатах окна с прижатием к рабочей + * области: панель не уходит за края экрана и не обрезает подписи, а её высота + * ограничивается свободным местом (содержимое прокручивается). + * + * Используется `Popover` и `Menu`: панели крепятся к кнопкам у нижнего края + * сайдбара, где обычное absolute-позиционирование уезжало за пределы окна. + */ +export function useAnchoredPosition({ + open, + anchorRef, + panelRef, + placement, + align, +}: AnchoredPositionOptions): AnchoredPosition | null { + const [position, setPosition] = useState(null); + + const update = useCallback(() => { + const anchor = anchorRef.current; + const panel = panelRef.current; + if (anchor === null || panel === null) { + return; + } + const anchorRect = anchor.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Свободное место сверху и снизу: панель раскрывается туда, где его больше. + const spaceAbove = anchorRect.top - ANCHOR_MARGIN; + const spaceBelow = viewportHeight - anchorRect.bottom - ANCHOR_MARGIN; + const preferTop = placement === 'top'; + const placeTop = preferTop + ? spaceAbove >= Math.min(panelRect.height, spaceBelow) || spaceAbove >= spaceBelow + : spaceBelow < Math.min(panelRect.height, spaceAbove); + const available = placeTop ? spaceAbove : spaceBelow; + const maxHeight = Math.max(120, Math.floor(available - ANCHOR_MARGIN)); + + // По горизонтали прижимаем к нужному краю якоря и к границам окна. + const width = panelRect.width; + const rawLeft = align === 'right' ? anchorRect.right - width : anchorRect.left; + const maxLeft = Math.max(ANCHOR_MARGIN, viewportWidth - width - ANCHOR_MARGIN); + const left = Math.min(Math.max(rawLeft, ANCHOR_MARGIN), maxLeft); + + const top = placeTop ? anchorRect.top - ANCHOR_MARGIN - panelRect.height : anchorRect.bottom + ANCHOR_MARGIN; + setPosition({ + left: Math.round(left), + top: Math.round(Math.max(ANCHOR_MARGIN, top)), + maxHeight, + placement: placeTop ? 'top' : 'bottom', + }); + }, [align, anchorRef, panelRef, placement]); + + useLayoutEffect(() => { + if (!open) { + setPosition(null); + return; + } + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, update]); + + return position; +} diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index 603ba7d..a7919ea 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -162,7 +162,7 @@ export default function AppLayout() { */}
{isFriends ? ( diff --git a/web/src/pages/app/UserPanel.tsx b/web/src/pages/app/UserPanel.tsx index 8d70c20..2ba93ea 100644 --- a/web/src/pages/app/UserPanel.tsx +++ b/web/src/pages/app/UserPanel.tsx @@ -76,10 +76,13 @@ function PanelControl({ children, }: PanelControlProps) { const anchorRef: RefObject = useRef(null); + // Группа «стрелка + кнопка»: поповер прижимается к её краю. + const groupRef: RefObject = useRef(null); return ( -
- {/* Узкая стрелка: панель шириной 240px, место здесь на счету. */} +
+ {/* Прямоугольник-стрелка во всю высоту основной кнопки: по нему легко + попасть мышью, ширина — около трети кнопки (жалоба пользователя). */}