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
+22 -4
View File
@@ -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<HTMLDivElement | null>(null);
const fallbackAnchor = useRef<HTMLElement | null>(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}
<div className="gl-composer-scroll min-h-0 flex-1 overflow-y-auto p-1">{children}</div>
</div>
);
}
+25 -4
View File
@@ -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<HTMLElement | null>;
/** Куда раскрывается панель: вверх (по умолчанию) или вниз. */
placement?: 'top' | 'bottom';
align?: 'left' | 'right';
@@ -32,6 +35,14 @@ export function Popover({
className = '',
}: PopoverProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const fallbackAnchor = useRef<HTMLElement | null>(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}
{/* Содержимое прокручивается: иначе при нехватке места не дотянуться
до слайдера громкости (жалоба пользователя). */}
<div className="gl-composer-scroll min-h-0 flex-1 overflow-y-auto p-2">{children}</div>
</div>
);
}