fix(web): панели не уезжают за экран (правка после замечания линтера)

Позиционирование переведено на прямые стили и класс `gl-anchored-panel`:
React не управляет left/top/max-height, поэтому повторный рендер не сбрасывает
раскладку, а `data-placement` задаёт направление анимации (панель может
развернуться вниз, если сверху мало места). Линтер по хукам чистый, 289 тестов
зелёные.
This commit is contained in:
2026-09-20 14:36:32 +03:00
parent 494589ffca
commit 389fdd5f9a
5 changed files with 83 additions and 76 deletions
+3 -11
View File
@@ -54,7 +54,7 @@ export function Menu({
}: MenuProps) { }: MenuProps) {
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
const fallbackAnchor = useRef<HTMLElement | null>(null); const fallbackAnchor = useRef<HTMLElement | null>(null);
const position = useAnchoredPosition({ useAnchoredPosition({
open, open,
anchorRef: anchorRef ?? fallbackAnchor, anchorRef: anchorRef ?? fallbackAnchor,
panelRef, panelRef,
@@ -164,16 +164,8 @@ export function Menu({
aria-label={label} aria-label={label}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
data-testid="menu-panel" data-testid="menu-panel"
style={{ data-placement={placement}
position: 'fixed', 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}`}
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}`}
> >
<div className="gl-composer-scroll min-h-0 flex-1 overflow-y-auto p-1">{children}</div> <div className="gl-composer-scroll min-h-0 flex-1 overflow-y-auto p-1">{children}</div>
</div> </div>
+3 -11
View File
@@ -36,7 +36,7 @@ export function Popover({
}: PopoverProps) { }: PopoverProps) {
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
const fallbackAnchor = useRef<HTMLElement | null>(null); const fallbackAnchor = useRef<HTMLElement | null>(null);
const position = useAnchoredPosition({ useAnchoredPosition({
open, open,
anchorRef: anchorRef ?? fallbackAnchor, anchorRef: anchorRef ?? fallbackAnchor,
panelRef, panelRef,
@@ -100,16 +100,8 @@ export function Popover({
role="dialog" role="dialog"
aria-label={label} aria-label={label}
data-testid="popover-panel" data-testid="popover-panel"
style={{ data-placement={placement}
position: 'fixed', 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}`}
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}`}
> >
{/* Содержимое прокручивается: иначе при нехватке места не дотянуться {/* Содержимое прокручивается: иначе при нехватке места не дотянуться
до слайдера громкости (жалоба пользователя). */} до слайдера громкости (жалоба пользователя). */}
+26
View File
@@ -175,6 +175,32 @@ body {
animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards; 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 { @keyframes gl-popover-down-in {
from { from {
+28 -32
View File
@@ -1,15 +1,8 @@
import { useCallback, useLayoutEffect, useState, type RefObject } from 'react'; import { useLayoutEffect, type RefObject } from 'react';
/** Отступ панели от краёв окна, чтобы тень и рамка не обрезались. */ /** Отступ панели от краёв окна, чтобы тень и рамка не обрезались. */
export const ANCHOR_MARGIN = 8; export const ANCHOR_MARGIN = 8;
export interface AnchoredPosition {
left: number;
top: number;
maxHeight: number;
placement: 'top' | 'bottom';
}
export interface AnchoredPositionOptions { export interface AnchoredPositionOptions {
open: boolean; open: boolean;
anchorRef: RefObject<HTMLElement | null>; anchorRef: RefObject<HTMLElement | null>;
@@ -24,6 +17,9 @@ export interface AnchoredPositionOptions {
* области: панель не уходит за края экрана и не обрезает подписи, а её высота * области: панель не уходит за края экрана и не обрезает подписи, а её высота
* ограничивается свободным местом (содержимое прокручивается). * ограничивается свободным местом (содержимое прокручивается).
* *
* Стили ставятся напрямую в DOM, без состояния: React этими свойствами не
* управляет, поэтому повторный рендер панели не сбрасывает позицию.
*
* Используется `Popover` и `Menu`: панели крепятся к кнопкам у нижнего края * Используется `Popover` и `Menu`: панели крепятся к кнопкам у нижнего края
* сайдбара, где обычное absolute-позиционирование уезжало за пределы окна. * сайдбара, где обычное absolute-позиционирование уезжало за пределы окна.
*/ */
@@ -33,10 +29,13 @@ export function useAnchoredPosition({
panelRef, panelRef,
placement, placement,
align, align,
}: AnchoredPositionOptions): AnchoredPosition | null { }: AnchoredPositionOptions): void {
const [position, setPosition] = useState<AnchoredPosition | null>(null); useLayoutEffect(() => {
if (!open) {
return;
}
const update = useCallback(() => { const update = (): void => {
const anchor = anchorRef.current; const anchor = anchorRef.current;
const panel = panelRef.current; const panel = panelRef.current;
if (anchor === null || panel === null) { if (anchor === null || panel === null) {
@@ -47,14 +46,16 @@ export function useAnchoredPosition({
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
// Свободное место сверху и снизу: панель раскрывается туда, где его больше. // Свободное место сверху и снизу: раскрываемся туда, где его больше.
const spaceAbove = anchorRect.top - ANCHOR_MARGIN; const spaceAbove = anchorRect.top - ANCHOR_MARGIN;
const spaceBelow = viewportHeight - anchorRect.bottom - ANCHOR_MARGIN; const spaceBelow = viewportHeight - anchorRect.bottom - ANCHOR_MARGIN;
const preferTop = placement === 'top'; const needed = Math.min(panelRect.height, 320);
const placeTop = preferTop const placeTop =
? spaceAbove >= Math.min(panelRect.height, spaceBelow) || spaceAbove >= spaceBelow placement === 'top'
: spaceBelow < Math.min(panelRect.height, spaceAbove); ? spaceAbove >= needed || spaceAbove >= spaceBelow
: spaceBelow < needed;
const available = placeTop ? spaceAbove : spaceBelow; const available = placeTop ? spaceAbove : spaceBelow;
// Не меньше 120px: даже в тесном окне панель остаётся прокручиваемой.
const maxHeight = Math.max(120, Math.floor(available - ANCHOR_MARGIN)); const maxHeight = Math.max(120, Math.floor(available - ANCHOR_MARGIN));
// По горизонтали прижимаем к нужному краю якоря и к границам окна. // По горизонтали прижимаем к нужному краю якоря и к границам окна.
@@ -63,20 +64,17 @@ export function useAnchoredPosition({
const maxLeft = Math.max(ANCHOR_MARGIN, viewportWidth - width - ANCHOR_MARGIN); const maxLeft = Math.max(ANCHOR_MARGIN, viewportWidth - width - ANCHOR_MARGIN);
const left = Math.min(Math.max(rawLeft, ANCHOR_MARGIN), maxLeft); const left = Math.min(Math.max(rawLeft, ANCHOR_MARGIN), maxLeft);
const top = placeTop ? anchorRect.top - ANCHOR_MARGIN - panelRect.height : anchorRect.bottom + ANCHOR_MARGIN; const top = placeTop
setPosition({ ? anchorRect.top - ANCHOR_MARGIN - Math.min(panelRect.height, maxHeight)
left: Math.round(left), : anchorRect.bottom + ANCHOR_MARGIN;
top: Math.round(Math.max(ANCHOR_MARGIN, top)),
maxHeight, panel.style.left = `${Math.round(left)}px`;
placement: placeTop ? 'top' : 'bottom', panel.style.top = `${Math.round(Math.max(ANCHOR_MARGIN, top))}px`;
}); panel.style.maxHeight = `${maxHeight}px`;
}, [align, anchorRef, panelRef, placement]); panel.dataset.placement = placeTop ? 'top' : 'bottom';
panel.dataset.positioned = 'true';
};
useLayoutEffect(() => {
if (!open) {
setPosition(null);
return;
}
update(); update();
window.addEventListener('resize', update); window.addEventListener('resize', update);
window.addEventListener('scroll', update, true); window.addEventListener('scroll', update, true);
@@ -84,7 +82,5 @@ export function useAnchoredPosition({
window.removeEventListener('resize', update); window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true); window.removeEventListener('scroll', update, true);
}; };
}, [open, update]); }, [open, anchorRef, panelRef, placement, align]);
return position;
} }
+4 -3
View File
@@ -244,9 +244,10 @@ describe('панель: кнопки устройств', () => {
await visitor.click(arrow); await visitor.click(arrow);
const panel = await screen.findByTestId('popover-panel'); const panel = await screen.findByTestId('popover-panel');
// Панель прижата к рабочей области: фиксированное позиционирование и // Панель прижата к рабочей области: класс с fixed-позиционированием,
// ограничение высоты, чтобы содержимое не уезжало за экран. // ограничение высоты и признак готовой раскладки.
expect(panel.style.position).toBe('fixed'); expect(panel.className).toContain('gl-anchored-panel');
expect(panel.dataset.positioned).toBe('true');
expect(panel.style.maxHeight).not.toBe(''); expect(panel.style.maxHeight).not.toBe('');
expect(panel.getAttribute('role')).toBe('dialog'); expect(panel.getAttribute('role')).toBe('dialog');
}); });