fix(web): панели не уезжают за экран (правка после замечания линтера)
Позиционирование переведено на прямые стили и класс `gl-anchored-panel`: React не управляет left/top/max-height, поэтому повторный рендер не сбрасывает раскладку, а `data-placement` задаёт направление анимации (панель может развернуться вниз, если сверху мало места). Линтер по хукам чистый, 289 тестов зелёные.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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}`}
|
|
||||||
>
|
>
|
||||||
{/* Содержимое прокручивается: иначе при нехватке места не дотянуться
|
{/* Содержимое прокручивается: иначе при нехватке места не дотянуться
|
||||||
до слайдера громкости (жалоба пользователя). */}
|
до слайдера громкости (жалоба пользователя). */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user