Skip to content
6 changes: 6 additions & 0 deletions src/components/ContentWithPopup/ContentWithPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import React from 'react';
import type {PopupProps} from '@gravity-ui/uikit';
import {Popup} from '@gravity-ui/uikit';

import {getPopupScrollContainer} from '../HoverPopup/getPopupScrollContainer';

interface ContentWithPopupProps extends PopupProps {
content: React.ReactNode;
className?: string;
Expand All @@ -17,11 +19,13 @@ export const ContentWithPopup = ({
pinOnClick,
hasArrow = true,
placement = ['top', 'bottom'],
floatingStyles,
...props
}: ContentWithPopupProps) => {
const [isPopupVisible, setIsPopupVisible] = React.useState(false);
const [isPinned, setIsPinned] = React.useState(false);
const anchor = React.useRef(null);
const container = getPopupScrollContainer(anchor.current);

const showPopup = () => {
setIsPopupVisible(true);
Expand All @@ -42,6 +46,8 @@ export const ContentWithPopup = ({
return (
<React.Fragment>
<Popup
container={container}
Comment thread
StekPerepolnen marked this conversation as resolved.
floatingStyles={{fontFamily: 'var(--g-text-body-font-family)', ...floatingStyles}}
anchorElement={anchor.current}
open={isPinned || isPopupVisible}
placement={placement}
Expand Down
6 changes: 6 additions & 0 deletions src/components/HoverPopup/HoverPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import debounce from 'lodash/debounce';

import {YDB_POPOVER_CLASS_NAME} from '../../utils/constants';

import {getPopupScrollContainer} from './getPopupScrollContainer';

const DEBOUNCE_TIMEOUT = 100;

function useVisibleAnchor(anchorElement: HTMLElement | null, open: boolean) {
Expand Down Expand Up @@ -148,6 +150,7 @@ export const HoverPopup = ({
const anchorElement = anchorRef?.current || anchor.current;
// Clipping a paired disk must not clear the shared hover state via onHidePopup.
const isAnchorVisible = useVisibleAnchor(anchorElement, open);
const container = getPopupScrollContainer(anchorElement);

return (
<React.Fragment>
Expand All @@ -156,6 +159,9 @@ export const HoverPopup = ({
</span>
{anchorElement ? (
<Popup
container={container}
// Keep portal typography when the page uses a different font.
floatingStyles={{fontFamily: 'var(--g-text-body-font-family)'}}
anchorElement={anchorElement}
onOpenChange={(_open, _event, reason) => {
if (reason === 'escape-key') {
Expand Down
77 changes: 77 additions & 0 deletions src/components/HoverPopup/getPopupScrollContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {ThemeProvider} from '@gravity-ui/uikit';
import {fireEvent, render, screen} from '@testing-library/react';

import {ContentWithPopup} from '../ContentWithPopup/ContentWithPopup';

import {getPopupScrollContainer} from './getPopupScrollContainer';

function makeScrollable(element: HTMLElement) {
element.style.setProperty('overflow-y', 'auto');
Object.defineProperties(element, {
clientHeight: {value: 100},
scrollHeight: {value: 200},
});
}

test('preserves document portals and keeps fullscreen portals inside the fullscreen boundary', () => {
const outer = document.createElement('div');
const fullscreen = document.createElement('div');
const anchor = document.createElement('span');
document.body.append(outer);
outer.append(fullscreen);
fullscreen.append(anchor);
const original = Object.getOwnPropertyDescriptors(document);
try {
expect(getPopupScrollContainer(null)).toBeUndefined();
expect(getPopupScrollContainer(anchor)).toBeUndefined();
makeScrollable(outer);
expect(getPopupScrollContainer(anchor)).toBe(outer);
Object.defineProperty(document, 'scrollingElement', {configurable: true, value: outer});
expect(getPopupScrollContainer(anchor)).toBeUndefined();
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
value: fullscreen,
});
expect(getPopupScrollContainer(anchor)).toBe(fullscreen);
} finally {
outer.remove();
for (const key of ['scrollingElement', 'fullscreenElement']) {
if (original[key]) {
Object.defineProperty(document, key, original[key]);
} else {
Reflect.deleteProperty(document, key);
}
}
}
});

test.each([false, true])(
'ContentWithPopup renders in the selected portal (override: %s)',
async (override) => {
const host = document.createElement('div');
makeScrollable(host);
document.body.append(host);
const explicitContainer = document.createElement('div');
document.body.append(explicitContainer);
const view = render(
<ThemeProvider theme="light">
<ContentWithPopup
content="Popup details"
{...(override ? {container: explicitContainer} : {})}
>
Open popup
</ContentWithPopup>
</ThemeProvider>,
{container: host},
);
try {
fireEvent.mouseEnter(screen.getByText('Open popup'));
const popup = await screen.findByText('Popup details');
expect((override ? explicitContainer : host).contains(popup)).toBe(true);
} finally {
view.unmount();
host.remove();
explicitContainer.remove();
}
},
);
19 changes: 19 additions & 0 deletions src/components/HoverPopup/getPopupScrollContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function getPopupScrollContainer(anchor: HTMLElement | null) {
if (!anchor) {
return undefined;
}
const doc = anchor.ownerDocument;
let parent = anchor.parentElement;
while (parent) {
if (
parent === doc.fullscreenElement ||
(/auto|scroll/.test(doc.defaultView?.getComputedStyle(parent).overflowY ?? '') &&
parent.scrollHeight > parent.clientHeight)
Comment thread
StekPerepolnen marked this conversation as resolved.
) {
// Document scrolling already works through the default portal.
return parent === doc.scrollingElement ? undefined : parent;
}
Comment thread
StekPerepolnen marked this conversation as resolved.
parent = parent.parentElement;
}
return undefined;
}
36 changes: 36 additions & 0 deletions tests/suites/storage/vdiskColoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,42 @@ test('keeps paired disk popups inside the viewport without expanding the page',
expect(maxPageWidthWhileClosing).toBe(pageWidth);
});

test('wheel over disk popups scrolls the page', async ({page}) => {
const response = createMockStorageGroupsResponse();
const group = response.StorageGroups?.[0];
if (!group) {
throw new Error('Missing storage group fixture');
}
response.StorageGroups = Array.from({length: 40}, (_, index) => ({
...group,
GroupId: String(9000000000 + index),
}));
response.TotalGroups = response.FoundGroups = 40;
await page.setViewportSize({width: 1500, height: 800});
await enableExpertMode(page, VDisksGroupBy.State, false);
await setupVDiskColoringMocks(page, response);
await gotoStoragePage(page, VDisksGroupBy.State, false);
await expectStorageGroupRowsReady(page, false);
const scroll = page.locator('.ydb-cluster');
for (const name of ['PDisk', 'VDisk']) {
await scroll.evaluate((element) => element.scrollTo({top: 0, left: 0}));
const row = getStorageGroupRow(page, 0);
await (name === 'PDisk' ? getPDiskItems(row) : getVDiskItems(row)).first().hover();
const action = page.getByRole('link', {name: `Go to ${name}`, exact: true});
await expect(action).toHaveAttribute('href', /nodeId=7000/);
const popup = page.locator('.ydb-popover').filter({has: action});
await expect(popup).toHaveCSS('max-height', 'none');
await expect(popup).toHaveCSS('overflow-y', 'visible');
await action.hover();
const before = await scroll.evaluate((element) => element.scrollTop);
await page.mouse.wheel(0, 200);
await expect
.poll(() => scroll.evaluate((element) => element.scrollTop))
.toBeGreaterThan(before);
await page.keyboard.press('Escape');
}
});

test.describe('VDisk Coloring - Expert Mode visual snapshots', () => {
test.describe.configure({timeout: 60_000});

Expand Down
Loading