diff --git a/web/src/components/ui/Menu.tsx b/web/src/components/ui/Menu.tsx index bfa1301..42bd442 100644 --- a/web/src/components/ui/Menu.tsx +++ b/web/src/components/ui/Menu.tsx @@ -54,7 +54,7 @@ export function Menu({ }: MenuProps) { const panelRef = useRef(null); const fallbackAnchor = useRef(null); - const position = useAnchoredPosition({ + useAnchoredPosition({ open, anchorRef: anchorRef ?? fallbackAnchor, panelRef, @@ -164,16 +164,8 @@ export function Menu({ aria-label={label} onKeyDown={onKeyDown} 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}`} + data-placement={placement} + className={`gl-anchored-panel flex min-w-56 flex-col overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 shadow-[var(--shadow-3)] ${className}`} >
{children}
diff --git a/web/src/components/ui/Popover.tsx b/web/src/components/ui/Popover.tsx index b2d52ff..e08b97a 100644 --- a/web/src/components/ui/Popover.tsx +++ b/web/src/components/ui/Popover.tsx @@ -36,7 +36,7 @@ export function Popover({ }: PopoverProps) { const panelRef = useRef(null); const fallbackAnchor = useRef(null); - const position = useAnchoredPosition({ + useAnchoredPosition({ open, anchorRef: anchorRef ?? fallbackAnchor, panelRef, @@ -100,16 +100,8 @@ export function Popover({ role="dialog" aria-label={label} 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}`} + data-placement={placement} + className={`gl-anchored-panel flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 shadow-[var(--shadow-3)] w-64 ${className}`} > {/* Содержимое прокручивается: иначе при нехватке места не дотянуться до слайдера громкости (жалоба пользователя). */} diff --git a/web/src/index.css b/web/src/index.css index 75dfa2b..a39846e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -175,6 +175,32 @@ body { animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards; } +/* + * Панели, которые позиционирует `useAnchoredPosition`: до замера они скрыты, + * после — прижаты к рабочей области по left/top/max-height (задаёт хук). + * position: fixed нужен, чтобы панель не уезжала за пределы окна. + */ +.gl-anchored-panel { + position: fixed; + z-index: 40; + visibility: hidden; +} + +.gl-anchored-panel[data-positioned='true'] { + visibility: visible; +} + +/* Направление анимации берём из data-placement: панель может развернуться. */ +.gl-anchored-panel[data-placement='top'] { + transform-origin: bottom; + animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards; +} + +.gl-anchored-panel[data-placement='bottom'] { + transform-origin: top; + animation: gl-popover-down-in var(--duration-fast) var(--ease-out) backwards; +} + /* То же меню, но раскрывающееся вниз (действия у сообщения). */ @keyframes gl-popover-down-in { from { diff --git a/web/src/lib/useAnchoredPosition.ts b/web/src/lib/useAnchoredPosition.ts index c5ec984..206214e 100644 --- a/web/src/lib/useAnchoredPosition.ts +++ b/web/src/lib/useAnchoredPosition.ts @@ -1,15 +1,8 @@ -import { useCallback, useLayoutEffect, useState, type RefObject } from 'react'; +import { useLayoutEffect, 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; @@ -24,6 +17,9 @@ export interface AnchoredPositionOptions { * области: панель не уходит за края экрана и не обрезает подписи, а её высота * ограничивается свободным местом (содержимое прокручивается). * + * Стили ставятся напрямую в DOM, без состояния: React этими свойствами не + * управляет, поэтому повторный рендер панели не сбрасывает позицию. + * * Используется `Popover` и `Menu`: панели крепятся к кнопкам у нижнего края * сайдбара, где обычное absolute-позиционирование уезжало за пределы окна. */ @@ -33,50 +29,52 @@ export function useAnchoredPosition({ 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]); - +}: AnchoredPositionOptions): void { useLayoutEffect(() => { if (!open) { - setPosition(null); return; } + + const update = (): void => { + 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 needed = Math.min(panelRect.height, 320); + const placeTop = + placement === 'top' + ? spaceAbove >= needed || spaceAbove >= spaceBelow + : spaceBelow < needed; + const available = placeTop ? spaceAbove : spaceBelow; + // Не меньше 120px: даже в тесном окне панель остаётся прокручиваемой. + 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 - Math.min(panelRect.height, maxHeight) + : anchorRect.bottom + ANCHOR_MARGIN; + + panel.style.left = `${Math.round(left)}px`; + panel.style.top = `${Math.round(Math.max(ANCHOR_MARGIN, top))}px`; + panel.style.maxHeight = `${maxHeight}px`; + panel.dataset.placement = placeTop ? 'top' : 'bottom'; + panel.dataset.positioned = 'true'; + }; + update(); window.addEventListener('resize', update); window.addEventListener('scroll', update, true); @@ -84,7 +82,5 @@ export function useAnchoredPosition({ window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true); }; - }, [open, update]); - - return position; + }, [open, anchorRef, panelRef, placement, align]); } diff --git a/web/tests/userPanel.test.tsx b/web/tests/userPanel.test.tsx index 8c5cb2d..b6abf4f 100644 --- a/web/tests/userPanel.test.tsx +++ b/web/tests/userPanel.test.tsx @@ -244,9 +244,10 @@ describe('панель: кнопки устройств', () => { await visitor.click(arrow); const panel = await screen.findByTestId('popover-panel'); - // Панель прижата к рабочей области: фиксированное позиционирование и - // ограничение высоты, чтобы содержимое не уезжало за экран. - expect(panel.style.position).toBe('fixed'); + // Панель прижата к рабочей области: класс с fixed-позиционированием, + // ограничение высоты и признак готовой раскладки. + expect(panel.className).toContain('gl-anchored-panel'); + expect(panel.dataset.positioned).toBe('true'); expect(panel.style.maxHeight).not.toBe(''); expect(panel.getAttribute('role')).toBe('dialog'); });