mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(flow-engine): fit dropdown to viewport (#10379)
This commit is contained in:
@@ -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' }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user