fix(web): крупные стрелки устройств и панели, не уезжающие за экран

- стрелки настроек микрофона, камеры и наушников стали прямоугольниками во всю
  высоту основной кнопки (32 px) и шириной около трети кнопки: по ним легко
  попасть, группы не наслаиваются и не выходят за панель (сайдбар расширен до
  256 px, у групп фиксированная ширина 36 px);
- поповеры и меню теперь позиционируются от рабочей области: новая
  `useAnchoredPosition` прижимает панель к краям окна, выбирает сторону с
  большим запасом места и ограничивает высоту, а содержимое прокручивается —
  подписи устройств и слайдер громкости больше не обрезаются;
- тесты: геометрия группы стрелки, открытие меню и ограничение положения
  панели в пределах окна (289 тестов Vitest).
This commit is contained in:
2026-09-20 14:35:08 +03:00
parent a126f50468
commit 494589ffca
6 changed files with 189 additions and 17 deletions
+90
View File
@@ -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<HTMLElement | null>;
panelRef: RefObject<HTMLElement | null>;
/** Предпочтительное направление раскрытия. */
placement: 'top' | 'bottom';
align: 'left' | 'right';
}
/**
* Позиционирование всплывающей панели в координатах окна с прижатием к рабочей
* области: панель не уходит за края экрана и не обрезает подписи, а её высота
* ограничивается свободным местом (содержимое прокручивается).
*
* Используется `Popover` и `Menu`: панели крепятся к кнопкам у нижнего края
* сайдбара, где обычное absolute-позиционирование уезжало за пределы окна.
*/
export function useAnchoredPosition({
open,
anchorRef,
panelRef,
placement,
align,
}: AnchoredPositionOptions): AnchoredPosition | null {
const [position, setPosition] = useState<AnchoredPosition | null>(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;
}