fix(flow-engine): fit dropdown to viewport (#10379)

This commit is contained in:
Zeke Zhang
2026-08-18 08:21:37 +08:00
committed by GitHub
parent 1cf44f4254
commit 91d8ecc846
2 changed files with 230 additions and 13 deletions
@@ -8,8 +8,8 @@
*/
import { css } from '@emotion/css';
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin, theme } from 'antd';
import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useFlowEngine } from '../../provider';
// ==================== Types ====================
@@ -69,16 +69,6 @@ interface ExtendedMenuInfo {
// ==================== Custom Hooks ====================
/**
* 计算合适的下拉菜单最大高度
*/
const useNiceDropdownMaxHeight = () => {
return useMemo(() => {
const maxHeight = Math.min(window.innerHeight * 0.6, 400);
return maxHeight;
}, []);
};
/**
* 处理异步菜单项加载的逻辑
*/
@@ -555,6 +545,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
const DROPDOWN_PERSIST_TTL_MS = 350;
const DEFAULT_DROPDOWN_MAX_HEIGHT = 400;
const MENU_CLOSE_DELAY = 0.3;
const SUBMENU_MOTION_DISABLED = {
motionEnter: false,
@@ -565,8 +556,11 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
const engine = useFlowEngine();
const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
const { token } = theme.useToken();
const triggerId = React.useId();
const showArrow = Boolean(props.arrow);
const [menuVisible, setMenuVisible] = useState(false);
const [dropdownMaxHeight, setDropdownMaxHeight] = useState(DEFAULT_DROPDOWN_MAX_HEIGHT);
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
const [rootItems, setRootItems] = useState<Item[]>([]);
const [rootLoading, setRootLoading] = useState(false);
@@ -578,7 +572,6 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
.filter(Boolean)
.join(' ');
const dropdownMaxHeight = useNiceDropdownMaxHeight();
const t = engine.translate.bind(engine);
// 解构 menu,避免在 effect 中直接依赖整个对象,减少不必要的重跑并满足 exhaustive-deps
@@ -597,6 +590,28 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
useSubmenuStyles(menuVisible, dropdownMaxHeight);
useLayoutEffect(() => {
if (!menuVisible) return;
const updateDropdownMaxHeight = () => {
const trigger = document.querySelector<HTMLElement>(`.${triggerOpenClassName}`);
if (!trigger) return;
const triggerRect = trigger.getBoundingClientRect();
const placementOffset = token.marginXXS + (showArrow ? token.sizePopupArrow / 2 : 0);
const reservedSpace = placementOffset + token.marginXXS;
const availableAbove = triggerRect.top - reservedSpace;
const availableBelow = window.innerHeight - triggerRect.bottom - reservedSpace;
const nextMaxHeight = Math.min(DEFAULT_DROPDOWN_MAX_HEIGHT, Math.max(0, availableAbove, availableBelow));
setDropdownMaxHeight(nextMaxHeight);
};
updateDropdownMaxHeight();
window.addEventListener('resize', updateDropdownMaxHeight);
return () => window.removeEventListener('resize', updateDropdownMaxHeight);
}, [menuVisible, showArrow, token.marginXXS, token.sizePopupArrow, triggerOpenClassName]);
const closeMenu = useCallback(() => {
setMenuVisible(false);
activeSearchKeyRef.current = null;
@@ -0,0 +1,202 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { act, render, screen, userEvent, waitFor } from '@nocobase/test/client';
import { ConfigProvider } from 'antd';
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FlowEngineProvider } from '../../../provider';
import { FlowEngine } from '../../../flowEngine';
import LazyDropdown from '../LazyDropdown';
const setViewportHeight = (height: number) => {
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: height,
});
};
describe('LazyDropdown', () => {
const originalInnerHeight = window.innerHeight;
afterEach(() => {
setViewportHeight(originalInnerHeight);
vi.restoreAllMocks();
});
it('uses the current viewport space when opening after the viewport height changes', async () => {
setViewportHeight(720);
const engine = new FlowEngine();
const user = userEvent.setup();
render(
<FlowEngineProvider engine={engine}>
<ConfigProvider>
<LazyDropdown
trigger={['click']}
menu={{
items: [{ key: 'field', label: 'Field' }],
}}
>
<button type="button">Open fields</button>
</LazyDropdown>
</ConfigProvider>
</FlowEngineProvider>,
);
const trigger = screen.getByRole('button', { name: 'Open fields' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
bottom: 196,
height: 32,
left: 49,
right: 141,
top: 164,
width: 92,
x: 49,
y: 164,
toJSON: () => ({}),
});
setViewportHeight(460);
await user.click(trigger);
const menu = await screen.findByRole('menu');
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '256px', overflowY: 'auto' }));
});
it('updates the available height while the dropdown is open', async () => {
setViewportHeight(720);
const engine = new FlowEngine();
const user = userEvent.setup();
render(
<FlowEngineProvider engine={engine}>
<ConfigProvider>
<LazyDropdown
trigger={['click']}
menu={{
items: [{ key: 'field', label: 'Field' }],
}}
>
<button type="button">Open fields</button>
</LazyDropdown>
</ConfigProvider>
</FlowEngineProvider>,
);
const trigger = screen.getByRole('button', { name: 'Open fields' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
bottom: 196,
height: 32,
left: 49,
right: 141,
top: 164,
width: 92,
x: 49,
y: 164,
toJSON: () => ({}),
});
await user.click(trigger);
const menu = await screen.findByRole('menu');
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '400px', overflowY: 'auto' }));
act(() => {
setViewportHeight(460);
window.dispatchEvent(new Event('resize'));
});
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '256px', overflowY: 'auto' }));
act(() => {
setViewportHeight(720);
window.dispatchEvent(new Event('resize'));
});
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '400px', overflowY: 'auto' }));
});
it('reserves the placement offset when the dropdown has an arrow', async () => {
setViewportHeight(460);
const engine = new FlowEngine();
const user = userEvent.setup();
render(
<FlowEngineProvider engine={engine}>
<ConfigProvider>
<LazyDropdown
arrow
trigger={['click']}
menu={{
items: [{ key: 'field', label: 'Field' }],
}}
>
<button type="button">Open fields</button>
</LazyDropdown>
</ConfigProvider>
</FlowEngineProvider>,
);
const trigger = screen.getByRole('button', { name: 'Open fields' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
bottom: 196,
height: 32,
left: 49,
right: 141,
top: 164,
width: 92,
x: 49,
y: 164,
toJSON: () => ({}),
});
await user.click(trigger);
const menu = await screen.findByRole('menu');
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '248px', overflowY: 'auto' }));
});
it('uses the space above when it is larger than the space below', async () => {
setViewportHeight(460);
const engine = new FlowEngine();
const user = userEvent.setup();
render(
<FlowEngineProvider engine={engine}>
<ConfigProvider>
<LazyDropdown
trigger={['click']}
menu={{
items: [{ key: 'field', label: 'Field' }],
}}
>
<button type="button">Open fields</button>
</LazyDropdown>
</ConfigProvider>
</FlowEngineProvider>,
);
const trigger = screen.getByRole('button', { name: 'Open fields' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
bottom: 332,
height: 32,
left: 49,
right: 141,
top: 300,
width: 92,
x: 49,
y: 300,
toJSON: () => ({}),
});
await user.click(trigger);
const menu = await screen.findByRole('menu');
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '292px', overflowY: 'auto' }));
});
});