fix(web): крупные стрелки устройств и панели, не уезжающие за экран
- стрелки настроек микрофона, камеры и наушников стали прямоугольниками во всю высоту основной кнопки (32 px) и шириной около трети кнопки: по ним легко попасть, группы не наслаиваются и не выходят за панель (сайдбар расширен до 256 px, у групп фиксированная ширина 36 px); - поповеры и меню теперь позиционируются от рабочей области: новая `useAnchoredPosition` прижимает панель к краям окна, выбирает сторону с большим запасом места и ограничивает высоту, а содержимое прокручивается — подписи устройств и слайдер громкости больше не обрезаются; - тесты: геометрия группы стрелки, открытие меню и ограничение положения панели в пределах окна (289 тестов Vitest).
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
|||||||
type RefObject,
|
type RefObject,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import { useAnchoredPosition } from '@/lib/useAnchoredPosition';
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** Доступное имя меню (озвучивается скринридером). */
|
/** Доступное имя меню (озвучивается скринридером). */
|
||||||
@@ -51,6 +53,14 @@ export function Menu({
|
|||||||
className = '',
|
className = '',
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
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);
|
const onCloseRef = useRef(onClose);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
@@ -153,11 +163,19 @@ export function Menu({
|
|||||||
role="menu"
|
role="menu"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onKeyDown={onKeyDown}
|
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)] ${
|
data-testid="menu-panel"
|
||||||
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
|
style={{
|
||||||
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||||
|
|
||||||
|
import { useAnchoredPosition } from '@/lib/useAnchoredPosition';
|
||||||
|
|
||||||
interface PopoverProps {
|
interface PopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** Доступное имя панели (озвучивается скринридером). */
|
/** Доступное имя панели (озвучивается скринридером). */
|
||||||
@@ -8,6 +10,7 @@ interface PopoverProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Элемент-якорь: клик по нему не считается кликом «вне панели». */
|
/** Элемент-якорь: клик по нему не считается кликом «вне панели». */
|
||||||
anchorRef?: RefObject<HTMLElement | null>;
|
anchorRef?: RefObject<HTMLElement | null>;
|
||||||
|
|
||||||
/** Куда раскрывается панель: вверх (по умолчанию) или вниз. */
|
/** Куда раскрывается панель: вверх (по умолчанию) или вниз. */
|
||||||
placement?: 'top' | 'bottom';
|
placement?: 'top' | 'bottom';
|
||||||
align?: 'left' | 'right';
|
align?: 'left' | 'right';
|
||||||
@@ -32,6 +35,14 @@ export function Popover({
|
|||||||
className = '',
|
className = '',
|
||||||
}: PopoverProps) {
|
}: PopoverProps) {
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
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);
|
const onCloseRef = useRef(onClose);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
@@ -88,11 +99,21 @@ export function Popover({
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={label}
|
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)] ${
|
data-testid="popover-panel"
|
||||||
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
|
style={{
|
||||||
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -162,7 +162,7 @@ export default function AppLayout() {
|
|||||||
*/}
|
*/}
|
||||||
<div
|
<div
|
||||||
data-testid="app-sidebar-column"
|
data-testid="app-sidebar-column"
|
||||||
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
className="flex w-64 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
||||||
>
|
>
|
||||||
{isFriends ? (
|
{isFriends ? (
|
||||||
<FriendsSidebar channelId={dmChannelId} />
|
<FriendsSidebar channelId={dmChannelId} />
|
||||||
|
|||||||
@@ -76,10 +76,13 @@ function PanelControl({
|
|||||||
children,
|
children,
|
||||||
}: PanelControlProps) {
|
}: PanelControlProps) {
|
||||||
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
|
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
|
||||||
|
// Группа «стрелка + кнопка»: поповер прижимается к её краю.
|
||||||
|
const groupRef: RefObject<HTMLDivElement | null> = useRef(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center">
|
<div ref={groupRef} className="relative flex h-8 w-9 shrink-0 items-stretch gap-0">
|
||||||
{/* Узкая стрелка: панель шириной 240px, место здесь на счету. */}
|
{/* Прямоугольник-стрелка во всю высоту основной кнопки: по нему легко
|
||||||
|
попасть мышью, ширина — около трети кнопки (жалоба пользователя). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
@@ -87,9 +90,11 @@ function PanelControl({
|
|||||||
aria-label={menuLabel}
|
aria-label={menuLabel}
|
||||||
title={menuLabel}
|
title={menuLabel}
|
||||||
onClick={onOpenMenu}
|
onClick={onOpenMenu}
|
||||||
className="rounded-[var(--radius-sm)] px-0.5 text-[8px] leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
className="flex w-3 shrink-0 items-center justify-center rounded-l-[var(--radius-sm)] text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:bg-surface-3"
|
||||||
>
|
>
|
||||||
▾
|
<svg aria-hidden="true" viewBox="0 0 8 6" className="h-1.5 w-2 fill-current">
|
||||||
|
<path d="M0 0h8L4 6z" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
@@ -98,7 +103,7 @@ function PanelControl({
|
|||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
title={toggleLabel}
|
title={toggleLabel}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={`rounded-[var(--radius-sm)] px-1 py-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
|
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
|
||||||
enabled ? 'text-fg' : 'text-danger'
|
enabled ? 'text-fg' : 'text-danger'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -108,9 +113,9 @@ function PanelControl({
|
|||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
label={menuLabel}
|
label={menuLabel}
|
||||||
anchorRef={anchorRef}
|
anchorRef={groupRef}
|
||||||
align="right"
|
align="right"
|
||||||
className="w-64 max-w-[70vw]"
|
className="w-64 max-w-[80vw]"
|
||||||
onClose={onCloseMenu}
|
onClose={onCloseMenu}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -222,7 +227,7 @@ export function UserPanel() {
|
|||||||
className="relative border-t border-border/40 bg-surface-2/80 p-2"
|
className="relative border-t border-border/40 bg-surface-2/80 p-2"
|
||||||
>
|
>
|
||||||
{/* Подложка панели: слева профиль, справа кнопки устройств и настроек. */}
|
{/* Подложка панели: слева профиль, справа кнопки устройств и настроек. */}
|
||||||
<div className="flex items-center gap-0.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-1.5 py-1.5">
|
<div className="flex items-stretch gap-1 overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-1.5 py-1.5">
|
||||||
<button
|
<button
|
||||||
ref={profileRef}
|
ref={profileRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -227,3 +227,41 @@ describe('панель пользователя: профиль', () => {
|
|||||||
expect(fresh.useVoiceStore.getState().headphonesEnabled).toBe(true);
|
expect(fresh.useVoiceStore.getState().headphonesEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('панель: кнопки устройств', () => {
|
||||||
|
it('стрелки — прямоугольники во всю высоту кнопки и открывают меню', async () => {
|
||||||
|
const panelRoot = await bootPanel();
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
|
||||||
|
// Стрелка микрофона доступна по подписи и лежит в одной группе с кнопкой.
|
||||||
|
const arrow = within(panelRoot).getByRole('button', { name: 'Настройки микрофона' });
|
||||||
|
const group = arrow.parentElement;
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(group?.className).toContain('h-8');
|
||||||
|
expect(group?.className).toContain('w-9');
|
||||||
|
// Кнопка-переключатель — сосед стрелки в той же группе.
|
||||||
|
expect(group?.querySelectorAll('button')).toHaveLength(2);
|
||||||
|
|
||||||
|
await visitor.click(arrow);
|
||||||
|
const panel = await screen.findByTestId('popover-panel');
|
||||||
|
// Панель прижата к рабочей области: фиксированное позиционирование и
|
||||||
|
// ограничение высоты, чтобы содержимое не уезжало за экран.
|
||||||
|
expect(panel.style.position).toBe('fixed');
|
||||||
|
expect(panel.style.maxHeight).not.toBe('');
|
||||||
|
expect(panel.getAttribute('role')).toBe('dialog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('попап настроек звука не выходит за пределы окна', async () => {
|
||||||
|
const panelRoot = await bootPanel();
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
|
||||||
|
await visitor.click(within(panelRoot).getByRole('button', { name: 'Настройки звука' }));
|
||||||
|
const panel = await screen.findByTestId('popover-panel');
|
||||||
|
const left = Number.parseInt(panel.style.left, 10);
|
||||||
|
const top = Number.parseInt(panel.style.top, 10);
|
||||||
|
expect(left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(left).toBeLessThan(window.innerWidth);
|
||||||
|
expect(top).toBeLessThan(window.innerHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user