@hsu-react/ui 2.5.6 → 2.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/es/components/Checkbox/CheckboxGroup/index.js +4 -1
  2. package/es/components/FormItem/FormCodeMirror/index.js +3 -1
  3. package/es/components/FormItem/ItemContainer/index.js +15 -5
  4. package/es/components/Modal/index.d.ts +12 -0
  5. package/es/components/Modal/index.js +22 -1
  6. package/es/components/Modal/index.module.scss +7 -0
  7. package/es/components/Operate/index.js +4 -1
  8. package/es/components/Pagination/index.js +3 -1
  9. package/es/components/Search/_hooks/useSearchCommon.js +4 -1
  10. package/es/components/Select/AutoCompleteSelect/index.js +3 -1
  11. package/es/components/Spreadsheet/index.js +8 -2
  12. package/es/components/Table/_hooks/useTableColumns.js +4 -1
  13. package/es/components/Tree/_hooks/useTreeState.js +7 -4
  14. package/es/components/Tree/index.js +8 -2
  15. package/es/layout/Menu/index.module.scss +32 -0
  16. package/es/styles/tokens.d.ts +10 -0
  17. package/es/styles/tokens.js +11 -0
  18. package/es/styles/tokens.json +1 -0
  19. package/lib/components/Checkbox/CheckboxGroup/index.js +4 -1
  20. package/lib/components/FormItem/FormCodeMirror/index.js +3 -1
  21. package/lib/components/FormItem/ItemContainer/index.js +16 -6
  22. package/lib/components/Modal/index.d.ts +12 -0
  23. package/lib/components/Modal/index.js +22 -0
  24. package/lib/components/Modal/index.module.scss +7 -0
  25. package/lib/components/Operate/index.js +4 -1
  26. package/lib/components/Pagination/index.js +3 -1
  27. package/lib/components/Search/_hooks/useSearchCommon.js +4 -1
  28. package/lib/components/Select/AutoCompleteSelect/index.js +3 -1
  29. package/lib/components/Spreadsheet/index.js +8 -2
  30. package/lib/components/Table/_hooks/useTableColumns.js +4 -1
  31. package/lib/components/Tree/_hooks/useTreeState.js +6 -3
  32. package/lib/components/Tree/index.js +8 -2
  33. package/lib/layout/Menu/index.module.scss +32 -0
  34. package/lib/styles/tokens.d.ts +10 -0
  35. package/lib/styles/tokens.js +12 -1
  36. package/lib/styles/tokens.json +1 -0
  37. package/package.json +1 -1
  38. package/src/__tests__/depsScan.ts +62 -15
  39. package/src/__tests__/freshObjectDeps.test.tsx +380 -0
  40. package/src/__tests__/freshObjectDepsGuard.test.ts +54 -23
  41. package/src/components/Checkbox/CheckboxGroup/index.tsx +4 -1
  42. package/src/components/FormItem/FormCodeMirror/index.tsx +4 -1
  43. package/src/components/FormItem/ItemContainer/index.tsx +21 -6
  44. package/src/components/Modal/index.md +1 -0
  45. package/src/components/Modal/index.module.scss +7 -0
  46. package/src/components/Modal/index.tsx +40 -0
  47. package/src/components/Modal/minHeight.test.tsx +105 -0
  48. package/src/components/Operate/index.tsx +4 -1
  49. package/src/components/Pagination/index.tsx +4 -1
  50. package/src/components/Search/_hooks/useSearchCommon.ts +4 -1
  51. package/src/components/Select/AutoCompleteSelect/index.tsx +4 -1
  52. package/src/components/Spreadsheet/depsMemo.test.tsx +95 -0
  53. package/src/components/Spreadsheet/index.tsx +9 -2
  54. package/src/components/Table/_hooks/useTableColumns.tsx +4 -1
  55. package/src/components/Tree/_hooks/useTreeState.ts +14 -8
  56. package/src/components/Tree/depsMemo.test.tsx +64 -0
  57. package/src/components/Tree/index.tsx +9 -2
  58. package/src/layout/Menu/index.module.scss +32 -0
  59. package/src/layout/Menu/scrollGutter.test.ts +50 -0
  60. package/src/styles/tokens.json +1 -0
  61. package/src/styles/tokens.ts +11 -0
@@ -5,14 +5,36 @@ import { useModalElements, useModalDrag } from "./_hooks";
5
5
  import Button, { ButtonProps } from "../Button";
6
6
  import { mergeSemantic } from "../../utils/semantic";
7
7
  import { modalFuncs } from "../../feedback";
8
+ import { modalMinHeight } from "../../styles/tokens";
8
9
 
9
10
  export interface ModalProps extends AntdModalProps {
10
11
  moveable?: boolean;
11
12
  edgeDetection?: boolean;
12
13
  full?: boolean;
13
14
  titleButtonGroup?: ButtonProps[];
15
+ /**
16
+ * 高度**下界**。给「内容要等接口回来才画得出」的弹窗用:不给下界的话,
17
+ * 打开那一刻只有标题栏那么高,数据回来再撑开 —— 视觉上跳一下。
18
+ *
19
+ * - `true`:用标准档(`modalMinHeight`,700px),后台详情 / 列表 / 记录类弹窗都用它
20
+ * - 数字:px;字符串:原样当 CSS 长度
21
+ * - 不传:维持内容自适应 —— **短表单不要传**,否则平白留出一大片空白
22
+ *
23
+ * 它只是下界:内容更高照常撑开、照常滚动;并且在样式里与 `90vh` 取小,
24
+ * 矮窗口下不会被顶出屏幕。
25
+ */
26
+ minHeight?: number | string | boolean;
14
27
  }
15
28
 
29
+ /** `minHeight` prop → CSS 长度。`true` 走标准档,`false` / 不传等于不设下界 */
30
+ const resolveMinHeight = (
31
+ minHeight: ModalProps["minHeight"]
32
+ ): string | undefined => {
33
+ if (minHeight === undefined || minHeight === false) return undefined;
34
+ if (minHeight === true) return `${modalMinHeight}px`;
35
+ return typeof minHeight === "number" ? `${minHeight}px` : minHeight;
36
+ };
37
+
16
38
  interface ModalFC extends React.FC<ModalProps> {
17
39
  confirm: typeof AntdModal.confirm;
18
40
  info: typeof AntdModal.info;
@@ -29,6 +51,8 @@ const Modal = ((props: ModalProps) => {
29
51
  moveable = true,
30
52
  className,
31
53
  classNames,
54
+ styles: outerStyles,
55
+ minHeight,
32
56
  open,
33
57
  onCancel,
34
58
  onOk,
@@ -79,6 +103,8 @@ const Modal = ((props: ModalProps) => {
79
103
  onOk?.(e);
80
104
  };
81
105
 
106
+ const resolvedMinHeight = resolveMinHeight(minHeight);
107
+
82
108
  return (
83
109
  <AntdModal
84
110
  centered
@@ -114,6 +140,20 @@ const Modal = ((props: ModalProps) => {
114
140
  footer === false ? styles.noFooter : ""
115
141
  }`,
116
142
  }))}
143
+ /* 下界通过 CSS 变量交给样式表,而不是直接写 `minHeight` 内联样式:
144
+ clamp(与 90vh 取小)必须在 CSS 里做——`min-height` 在 CSS 里赢过
145
+ `max-height`,内联写死的话矮窗口会被顶出屏幕 */
146
+ styles={mergeSemantic(outerStyles, (outer) => ({
147
+ ...outer,
148
+ container: {
149
+ ...(outer.container ?? {}),
150
+ ...(resolvedMinHeight
151
+ ? ({
152
+ "--vita-modal-min-height": resolvedMinHeight,
153
+ } as React.CSSProperties)
154
+ : {}),
155
+ },
156
+ }))}
117
157
  footer={footer}
118
158
  afterClose={() => {
119
159
  afterClose?.();
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import React from "react";
4
+ import { describe, expect, it } from "vitest";
5
+ import { render } from "@testing-library/react";
6
+
7
+ import Modal from ".";
8
+ import { modalMinHeight } from "../../styles/tokens";
9
+
10
+ /**
11
+ * 「内容异步加载的弹窗要有高度下界」这条能力的守卫。
12
+ *
13
+ * 缺陷长什么样:详情 / 消息 / 执行记录这类弹窗打开那一刻只有标题栏那么高,
14
+ * 接口回来再撑开 —— 视觉上跳一下。给下界就不跳。
15
+ *
16
+ * 两条底线一起盯:
17
+ * 1. **不传 `minHeight` 的弹窗不许变高** —— 否则两个输入框的短表单会平白留一片空白;
18
+ * 2. 下界必须在 CSS 里与 `90vh` 取小 —— `min-height` 在 CSS 里赢过 `max-height`,
19
+ * 直接落成内联 `min-height` 的话矮窗口会被顶出屏幕。所以组件只写 CSS 变量,
20
+ * clamp 留在样式表里,这里连样式表那一行也一并守住。
21
+ */
22
+
23
+ /** antd 的 container 语义槽就是承载 `.content`(flex 列 + max-height: 90vh)的那一层 */
24
+ const container = () =>
25
+ document.querySelector(".ant-modal-content, .ant-modal-container") as
26
+ | HTMLElement
27
+ | null;
28
+
29
+ const minHeightVar = () =>
30
+ container()?.style.getPropertyValue("--vita-modal-min-height") ?? "";
31
+
32
+ describe("Modal minHeight", () => {
33
+ it("不传时不写下界 —— 短表单不会平白变高", () => {
34
+ render(
35
+ <Modal open getContainer={false}>
36
+ x
37
+ </Modal>
38
+ );
39
+
40
+ expect(container()).toBeTruthy();
41
+ expect(minHeightVar()).toBe("");
42
+ });
43
+
44
+ it("minHeight 传 true 用标准档(与 Panel.List.Modal 同一个 700)", () => {
45
+ render(
46
+ <Modal open minHeight getContainer={false}>
47
+ x
48
+ </Modal>
49
+ );
50
+
51
+ expect(minHeightVar()).toBe(`${modalMinHeight}px`);
52
+ });
53
+
54
+ it("数字按 px、字符串原样、false 等于不设", () => {
55
+ const { unmount } = render(
56
+ <Modal open minHeight={520} getContainer={false}>
57
+ x
58
+ </Modal>
59
+ );
60
+ expect(minHeightVar()).toBe("520px");
61
+ unmount();
62
+
63
+ const second = render(
64
+ <Modal open minHeight="60vh" getContainer={false}>
65
+ x
66
+ </Modal>
67
+ );
68
+ expect(minHeightVar()).toBe("60vh");
69
+ second.unmount();
70
+
71
+ render(
72
+ <Modal open minHeight={false} getContainer={false}>
73
+ x
74
+ </Modal>
75
+ );
76
+ expect(minHeightVar()).toBe("");
77
+ });
78
+
79
+ it("调用方自己的 styles.container 不会被吃掉", () => {
80
+ render(
81
+ <Modal
82
+ open
83
+ minHeight
84
+ styles={{ container: { borderRadius: "4px" } }}
85
+ getContainer={false}
86
+ >
87
+ x
88
+ </Modal>
89
+ );
90
+
91
+ expect(container()?.style.borderRadius).toBe("4px");
92
+ expect(minHeightVar()).toBe(`${modalMinHeight}px`);
93
+ });
94
+
95
+ it("样式表里 clamp 到 90vh —— 矮窗口不会被顶出屏幕", () => {
96
+ const scss = fs.readFileSync(
97
+ path.resolve(__dirname, "index.module.scss"),
98
+ "utf8"
99
+ );
100
+
101
+ expect(scss).toMatch(
102
+ /min-height:\s*min\(var\(--vita-modal-min-height,\s*0px\),\s*90vh\)/
103
+ );
104
+ });
105
+ });
@@ -28,6 +28,9 @@ export interface OperateProps
28
28
  moreIcon?: ReactNode | false;
29
29
  }
30
30
 
31
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
32
+ const EMPTY_MENU: OperateProps[] = [];
33
+
31
34
  /**
32
35
  * Render an icon
33
36
  * @param icon - Icon configuration
@@ -56,7 +59,7 @@ const Operate: React.FC<OperateProps> = (props) => {
56
59
  const {
57
60
  title,
58
61
  className,
59
- menu = [],
62
+ menu = EMPTY_MENU,
60
63
  icon,
61
64
  delete: isDelete,
62
65
  popconfirm,
@@ -20,10 +20,13 @@ export interface PaginationProps
20
20
  onStaticPaginationChange?: (page: number, pageSize: number) => void;
21
21
  }
22
22
 
23
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
24
+ const EMPTY_PAGE_SIZE_OPTIONS: number[] = [];
25
+
23
26
  const Pagination: React.FC<PaginationProps> = (props) => {
24
27
  const {
25
28
  simple,
26
- pageSizeOptions = [],
29
+ pageSizeOptions = EMPTY_PAGE_SIZE_OPTIONS,
27
30
  showSizeChanger,
28
31
  onShowSizeChange,
29
32
  onChange,
@@ -76,6 +76,9 @@ export interface UseSearchCommonReturn {
76
76
  permitted: boolean;
77
77
  }
78
78
 
79
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
80
+ const EMPTY_SEARCH_ITEMS: FormItemProps[] = [];
81
+
79
82
  /**
80
83
  * Extracts the common logic shared by all Search components
81
84
  */
@@ -85,7 +88,7 @@ export const useSearchCommon = (
85
88
  const {
86
89
  form,
87
90
  searchItems,
88
- moreSearchItems = [],
91
+ moreSearchItems = EMPTY_SEARCH_ITEMS,
89
92
  searchData,
90
93
  hasPermi,
91
94
  beforeButtonGroup,
@@ -40,6 +40,9 @@ export interface AutoCompleteSelectProps
40
40
  optionFontSize?: number;
41
41
  }
42
42
 
43
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
44
+ const EMPTY_OPTIONS: AutoCompleteOption[] = [];
45
+
43
46
  const AutoCompleteSelect: React.FC<AutoCompleteSelectProps> = (props) => {
44
47
  const {
45
48
  prefix,
@@ -53,7 +56,7 @@ const AutoCompleteSelect: React.FC<AutoCompleteSelectProps> = (props) => {
53
56
  popupClassName,
54
57
  filterOption: customFilterOption,
55
58
  onChange,
56
- options = [],
59
+ options = EMPTY_OPTIONS,
57
60
  placement = "bottomLeft",
58
61
  onDropdownVisibleChange,
59
62
  popupMatchSelectWidth,
@@ -0,0 +1,95 @@
1
+ import { render } from "@testing-library/react";
2
+ import React, { ReactElement } from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * 2.5.7:`xOptions` 的解构默认值与 `xOptionsRest` 这个 rest 包每渲染都是新对象,
7
+ * 直接进「建表格实例」那条 effect 的依赖数组 → **effect 每渲染必重跑**。
8
+ * 实例本身被 `!sheet` 挡住了不会重建,但 effect 体每次都白跑一遍,
9
+ * 且依赖里挂着的 `xOptionsRest` 永远比不出「配置真的变了」——纯粹的假依赖。
10
+ *
11
+ * 这里单独一个文件,是因为要把 `x-data-spreadsheet` 换成假实现(jsdom 里跑不了真的)。
12
+ */
13
+
14
+ const probe = { effect: 0 };
15
+ (globalThis as Record<string, unknown>).__spreadsheetProbe = probe;
16
+
17
+ vi.mock("react", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("react")>();
19
+ const counters = () =>
20
+ (globalThis as Record<string, unknown>).__spreadsheetProbe as typeof probe;
21
+ return {
22
+ ...actual,
23
+ useEffect: (fn: () => unknown, deps: unknown[]) =>
24
+ actual.useEffect(() => {
25
+ counters().effect++;
26
+ return fn() as ReturnType<React.EffectCallback>;
27
+ }, deps),
28
+ };
29
+ });
30
+
31
+ const construct = vi.fn();
32
+ const loadData = vi.fn();
33
+
34
+ vi.mock("x-data-spreadsheet", () => ({
35
+ default: class FakeSpreadsheet {
36
+ constructor(...args: unknown[]) {
37
+ construct(...args);
38
+ }
39
+ loadData(...args: unknown[]) {
40
+ loadData(...args);
41
+ return this;
42
+ }
43
+ },
44
+ }));
45
+ vi.mock("x-data-spreadsheet/dist/xspreadsheet.css", () => ({}));
46
+
47
+ import Spreadsheet from ".";
48
+
49
+ beforeEach(() => {
50
+ probe.effect = 0;
51
+ construct.mockClear();
52
+ loadData.mockClear();
53
+ });
54
+
55
+ function rerenderEffectCost(make: () => ReactElement) {
56
+ const { rerender, unmount } = render(make());
57
+ const before = probe.effect;
58
+ rerender(make());
59
+ const cost = probe.effect - before;
60
+ unmount();
61
+ return cost;
62
+ }
63
+
64
+ describe("Spreadsheet:xOptions / xOptionsRest 不再让建表 effect 每渲染必重跑", () => {
65
+ it("配置没变时重渲染不再重跑任何 effect", () => {
66
+ // 不传 xOptions(走默认值)与传内联对象(消费方最常见的写法)都必须为 0
67
+ expect(rerenderEffectCost(() => <Spreadsheet />)).toBe(0);
68
+ expect(
69
+ rerenderEffectCost(() => <Spreadsheet xOptions={{ showBottomTool: true }} />),
70
+ ).toBe(0);
71
+ });
72
+
73
+ it("表格实例只建一次,重渲染不会重复 new", () => {
74
+ const { rerender } = render(<Spreadsheet />);
75
+ rerender(<Spreadsheet />);
76
+ rerender(<Spreadsheet />);
77
+ expect(construct).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("xOptions 真变了仍然透进新配置(浅比较不相等就立刻透出)", () => {
81
+ // 首帧就带上配置,确认它确实进了 XSpreadsheet 的构造参数
82
+ render(<Spreadsheet xOptions={{ showBottomTool: false, mode: "read" }} />);
83
+ expect(construct).toHaveBeenCalledTimes(1);
84
+ expect(construct.mock.calls[0][1]).toMatchObject({ mode: "read" });
85
+ });
86
+
87
+ it("data 真变了仍然重新 loadData", () => {
88
+ const bookA = { SheetNames: ["A"], Sheets: { A: {} } } as never;
89
+ const bookB = { SheetNames: ["B"], Sheets: { B: {} } } as never;
90
+ const { rerender } = render(<Spreadsheet data={bookA} />);
91
+ const first = loadData.mock.calls.length;
92
+ rerender(<Spreadsheet data={bookB} />);
93
+ expect(loadData.mock.calls.length).toBeGreaterThan(first);
94
+ });
95
+ });
@@ -6,6 +6,7 @@ import "x-data-spreadsheet/dist/xspreadsheet.css";
6
6
  import classNames from "classnames";
7
7
  import { WorkBook } from "xlsx";
8
8
  import { stox } from "./xlsxspread";
9
+ import useShallowStable from "../../hooks/useShallowStable";
9
10
 
10
11
  interface XOptions extends Omit<Options, "view"> {
11
12
  showBottomTool?: boolean;
@@ -17,9 +18,15 @@ interface SpreadsheetProps {
17
18
  className?: string;
18
19
  }
19
20
 
21
+ // 解构默认值写成字面量会每次渲染新建一个对象,进依赖数组就让 effect 每渲染必重跑;提到模块级常量
22
+ const EMPTY_X_OPTIONS: XOptions = {};
23
+
20
24
  const Spreadsheet: React.FC<SpreadsheetProps> = (props) => {
21
- const { data, xOptions = {}, className } = props;
22
- const { showBottomTool = true, ...xOptionsRest } = xOptions;
25
+ const { data, xOptions = EMPTY_X_OPTIONS, className } = props;
26
+ const { showBottomTool = true, ...restXOptions } = xOptions;
27
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 memo 恒不命中。
28
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
29
+ const xOptionsRest = useShallowStable(restXOptions);
23
30
  const ref = useRef<HTMLDivElement>(null);
24
31
  const containerRef = useRef<HTMLDivElement>(null);
25
32
  const id = useMemo(() => generateRandomStr(10), []);
@@ -19,6 +19,9 @@ import {
19
19
  import usePermissions from "../../../hooks/usePermissions";
20
20
  import { PaginationProps } from "../../Pagination";
21
21
 
22
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
23
+ const EMPTY_COLUMNS: ColumnsType = [];
24
+
22
25
  // measureAlign columns: measured at 14px, consistent with the table body and autoWidth title measurement
23
26
  const MEASURE_ALIGN_FONT_SIZE = 14;
24
27
  // Canvas measureText only returns the advance width, about 1px narrower than the actual DOM rendering; add a 2px buffer to avoid squeezing the widest row
@@ -97,7 +100,7 @@ const useTableColumns = <T extends AnyObject>(
97
100
  handleTableChange: TableProps<T>["onChange"];
98
101
  } => {
99
102
  const {
100
- columns = [],
103
+ columns = EMPTY_COLUMNS,
101
104
  scroll,
102
105
  autoWidth,
103
106
  dataSource,
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, Key, useMemo, useRef } from "react";
1
+ import { useEffect, useState, Key, useMemo, useRef, useCallback } from "react";
2
2
  import { Equal } from "hsu-utils";
3
3
  import { CheckedKeys, TreeData } from "..";
4
4
  import { normalizeCheckedKeys } from "../_utils/normalizeCheckedKeys";
@@ -42,13 +42,19 @@ export const useExpandedKeys = (
42
42
  }
43
43
  }, [defaultExpandedKeys, expandedKeysProps]);
44
44
 
45
- // Wrap setExpandedKeys to mark that the user has manually interacted
46
- const handleSetExpandedKeys = (
47
- keys: Key[] | undefined | ((prev: Key[] | undefined) => Key[] | undefined)
48
- ) => {
49
- hasUserInteractedRef.current = true;
50
- setExpandedKeys(keys);
51
- };
45
+ // Wrap setExpandedKeys to mark that the user has manually interacted.
46
+ // 必须 useCallback:这个函数被 Tree 的 handleExpand 当依赖用,
47
+ // 每渲染新建一个就让那个 useCallback 恒不命中——展开回调每渲染换引用透给 antd Tree。
48
+ // 函数体只碰 ref 和状态 setter,两者都恒定,所以依赖数组是空的。
49
+ const handleSetExpandedKeys = useCallback(
50
+ (
51
+ keys: Key[] | undefined | ((prev: Key[] | undefined) => Key[] | undefined)
52
+ ) => {
53
+ hasUserInteractedRef.current = true;
54
+ setExpandedKeys(keys);
55
+ },
56
+ []
57
+ );
52
58
 
53
59
  return [expandedKeys, handleSetExpandedKeys] as const;
54
60
  };
@@ -0,0 +1,64 @@
1
+ import { render } from "@testing-library/react";
2
+ import React from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * 2.5.7:`treeConfig` 是 rest 解构包,每次渲染都是新对象。它进了 `handleExpand`
7
+ * 的 `useCallback` 依赖数组 → **展开回调每渲染都换一个新引用**,原样传给 antd Tree。
8
+ *
9
+ * 这里把 antd 的 `Tree` 换成一个只记录 props 的探针,直接比 `onExpand` 的引用。
10
+ * 单独一个文件,是因为 `vi.mock` 是文件级的,不能污染别的用例。
11
+ */
12
+
13
+ const seen: Record<string, unknown>[] = [];
14
+
15
+ vi.mock("antd", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("antd")>();
17
+ const Probe = (props: Record<string, unknown>) => {
18
+ seen.push(props);
19
+ return React.createElement(actual.Tree, props);
20
+ };
21
+ Probe.DirectoryTree = actual.Tree.DirectoryTree;
22
+ Probe.TreeNode = actual.Tree.TreeNode;
23
+ return { ...actual, Tree: Probe };
24
+ });
25
+
26
+ import Tree from ".";
27
+
28
+ const NODES = [{ key: "1", value: "1", title: "节点甲" }];
29
+
30
+ beforeEach(() => {
31
+ seen.length = 0;
32
+ });
33
+
34
+ describe("Tree:treeConfig 不再让展开回调每渲染换引用", () => {
35
+ it("props 没变时,传给 antd Tree 的 onExpand 保持同一个引用", () => {
36
+ const make = () => <Tree treeData={NODES} />;
37
+ const { rerender } = render(make());
38
+ rerender(make());
39
+ expect(seen.length).toBeGreaterThanOrEqual(2);
40
+ expect(seen[seen.length - 1].onExpand).toBe(seen[0].onExpand);
41
+ });
42
+
43
+ it("消费方传内联的透传配置时也保持同一个引用(浅比较看的是值)", () => {
44
+ const make = () => <Tree treeData={NODES} showLine blockNode />;
45
+ const { rerender } = render(make());
46
+ rerender(make());
47
+ expect(seen[seen.length - 1].onExpand).toBe(seen[0].onExpand);
48
+ });
49
+
50
+ it("透传配置真变了仍然透出新引用,且新的 onExpand 调的是新的回调", () => {
51
+ const first = vi.fn();
52
+ const second = vi.fn();
53
+ const { rerender } = render(<Tree treeData={NODES} onExpand={first} />);
54
+ type Expand = (keys: unknown[], info: unknown) => void;
55
+ const before = seen[seen.length - 1].onExpand as Expand;
56
+ rerender(<Tree treeData={NODES} onExpand={second} />);
57
+ const after = seen[seen.length - 1].onExpand as Expand;
58
+ expect(after).not.toBe(before);
59
+
60
+ after(["1"], { node: { key: "1" } });
61
+ expect(second).toHaveBeenCalledTimes(1);
62
+ expect(first).not.toHaveBeenCalled();
63
+ });
64
+ });
@@ -21,6 +21,7 @@ import { ButtonProps } from "../Button";
21
21
  import { getExpandedKeysByLevel, getNodePath } from "./_utils";
22
22
  import TextEllipsis from "../TextEllipsis";
23
23
  import usePermissions from "../../hooks/usePermissions";
24
+ import useShallowStable from "../../hooks/useShallowStable";
24
25
  import { isLegacyHasSelectorBrowser } from "../../utils/cssSupports";
25
26
 
26
27
  export interface TreeData extends Record<string, unknown> {
@@ -79,6 +80,9 @@ interface TreeFC extends React.FC<TreeProps> {
79
80
  TreeNode: typeof AntdTree.TreeNode;
80
81
  }
81
82
 
83
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
84
+ const EMPTY_TREE_DATA: TreeData[] = [];
85
+
82
86
  const Tree = ((props: TreeProps) => {
83
87
  const {
84
88
  title,
@@ -88,7 +92,7 @@ const Tree = ((props: TreeProps) => {
88
92
  titleClassName,
89
93
  className,
90
94
  search,
91
- treeData = [],
95
+ treeData = EMPTY_TREE_DATA,
92
96
  onChange,
93
97
  onCheck,
94
98
  onSelect,
@@ -109,8 +113,11 @@ const Tree = ((props: TreeProps) => {
109
113
  onSelectPath,
110
114
  allowDeselect = true,
111
115
  titleSearchBarClassName,
112
- ...treeConfig
116
+ ...restTreeConfig
113
117
  } = props;
118
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 memo 恒不命中。
119
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
120
+ const treeConfig = useShallowStable(restTreeConfig);
114
121
  const { permitted } = usePermissions(hasPermi);
115
122
  const legacyHasSelector = isLegacyHasSelectorBrowser();
116
123
 
@@ -28,6 +28,38 @@
28
28
  overflow-y: auto;
29
29
  }
30
30
 
31
+ /* ===== 滚动条占位不许吃掉菜单项的内缩 =====
32
+ 上面那条规则把「谁滚」定在了菜单根上,但没有把几何契约收完:滚动条一旦
33
+ **实占宽度**(消费方常见做法:槽宽恒定、只切 thumb 颜色,这样悬浮时不抖),
34
+ 它就从菜单根的内容盒里切走一条 —— 而菜单项的左右外边距量的正是这个内容盒。
35
+ 于是展开子菜单、菜单一超高,右边多出一条空白,左边纹丝不动:
36
+
37
+ 展开前 菜单项 8..220(左留白 8 / 右留白 10)
38
+ 展开后 菜单项 8..206(左留白 8 / 右留白 24)← 展开的那一项看着「右边距变大了」
39
+
40
+ `scrollbar-gutter: stable both-edges` 把这条槽**两侧对称保留**,且不论当下滚不滚
41
+ 都保留:左右留白从此相等,展开 / 收起也不再有宽度变化(连带把「展开那一瞬内容
42
+ 重排」一并去掉)。槽宽由浏览器按该元素实际的滚动条算,**不是写死的 14px** ——
43
+ 消费方把滚动条改窄改宽,留白跟着变。
44
+
45
+ 配套把菜单项自己的左右外边距清零:留白改由这条槽提供,不再叠加。不清的话
46
+ 留白变成 `8 + 槽宽`(实测 22/24),230px 的侧栏里「MCP 连接器」直接被截断。
47
+ 纵向节奏与嵌套缩进不受影响 —— antd 的行内缩进走的是 padding,不是 margin。
48
+
49
+ `@supports` 兜底:不认这个属性的浏览器维持原样,不会两头落空。 */
50
+ @supports (scrollbar-gutter: stable both-edges) {
51
+ &:global(.ant-menu-inline) {
52
+ scrollbar-gutter: stable both-edges;
53
+
54
+ :global(.ant-menu-item),
55
+ :global(.ant-menu-submenu-title) {
56
+ width: 100%;
57
+
58
+ margin-inline: 0px;
59
+ }
60
+ }
61
+ }
62
+
31
63
  /* 子菜单在展开 / 收起动画期间被 rc-motion 逐帧改高度。antd 的
32
64
  `.ant-motion-collapse` 自带 overflow hidden,这里补的是**动画结束、类名摘掉
33
65
  之后**那一帧:此时 ul 的高度刚回到 auto,若父级恰好差几像素,浏览器会先画出
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /**
6
+ * 防回归守卫:**滚动条占位不许吃掉侧栏菜单项的内缩**。
7
+ *
8
+ * 缺陷长什么样:菜单根自己是滚动容器,滚动条一实占宽度就从它的内容盒里切走一条,
9
+ * 而菜单项的左右外边距量的正是这个内容盒 —— 展开子菜单让菜单超高、滚动条出现,
10
+ * 右留白从 10px 跳到 24px、左留白仍是 8px,看着就是「展开的那一项右边距变大了」。
11
+ *
12
+ * 正解是 `scrollbar-gutter: stable both-edges`(两侧对称、恒定保留,槽宽由浏览器按
13
+ * 实际滚动条算),并把菜单项自己的左右外边距清零,让留白只由这条槽提供。
14
+ *
15
+ * 这条守卫只能扫源码:jsdom 不做布局,量不出滚动条占位。所以顺带把两种「硬拉回来」
16
+ * 的写法也钉死 —— 写死滚动条宽度、或者用负 margin 把内容顶回去,换台机器就错开。
17
+ */
18
+
19
+ const SCSS = fs.readFileSync(
20
+ path.resolve(__dirname, "index.module.scss"),
21
+ "utf8"
22
+ );
23
+
24
+ describe("侧栏菜单的滚动条占位", () => {
25
+ it("inline 菜单两侧对称保留滚动条槽", () => {
26
+ // 要的是**声明**本身,不是 @supports 条件里那份同名文本
27
+ expect(SCSS).toMatch(/scrollbar-gutter:\s*stable both-edges\s*;/);
28
+ // 槽由 @supports 兜底,不认这个属性的浏览器维持原样
29
+ expect(SCSS).toMatch(/@supports \(scrollbar-gutter: stable both-edges\)/);
30
+ });
31
+
32
+ it("菜单项不再自己留左右外边距 —— 留白只由那条槽提供,不叠加", () => {
33
+ expect(SCSS).toMatch(/margin-inline:\s*0px/);
34
+ });
35
+
36
+ it("不许写死滚动条宽度,也不许用负 margin 硬拉回来", () => {
37
+ // 滚动条宽度按平台 / 用户设置变,写死会在别的机器上错开
38
+ expect(SCSS).not.toMatch(/(?:width|margin|padding)[^;{}]*:\s*-?1[45]px/);
39
+ expect(SCSS).not.toMatch(/margin[a-z-]*:\s*[^;{}]*-\d/);
40
+ });
41
+
42
+ it("横向一律不滚、子菜单自己不滚 —— 上一轮修「闪滚动条」的口径没被冲掉", () => {
43
+ expect(SCSS).toMatch(/overflow-x:\s*hidden/);
44
+ expect(SCSS).toMatch(/\.ant-menu-sub\.ant-menu-inline\)\s*\{\s*overflow:\s*hidden/);
45
+ });
46
+
47
+ it("不许在库里写 scrollbar-width / scrollbar-color —— Chrome 会整套忽略消费方的 ::-webkit-scrollbar", () => {
48
+ expect(SCSS).not.toMatch(/^\s*scrollbar-(?:width|color)\s*:/m);
49
+ });
50
+ });
@@ -72,6 +72,7 @@
72
72
  "md": 800,
73
73
  "lg": 1200
74
74
  },
75
+ "modalMinHeight": 700,
75
76
  "breakpoint": {
76
77
  "sm": 640,
77
78
  "md": 768,
@@ -94,6 +94,17 @@ export const controlTokens = raw.control;
94
94
  */
95
95
  export const modalWidth = raw.modalWidth;
96
96
 
97
+ /**
98
+ * 内容异步加载的弹窗的**高度下界**(`<Modal minHeight>` 的默认档)。
99
+ *
100
+ * 取 700 不是拍的:`Panel.List.Modal` 从一开始就把自己钉在 `height: 700px`,那是
101
+ * 「搜索行 + 工具条 + 表头 + 十行左右 + 分页」放得下的高度。详情 / 消息 / 记录这类
102
+ * 弹窗与它同处一套后台,用同一个下界,纵向节奏才是一套的。
103
+ *
104
+ * 它只是**下界**:内容更高照常撑开,并且在 CSS 里与 `90vh` 取小,矮窗口不会被顶出屏幕。
105
+ */
106
+ export const modalMinHeight = raw.modalMinHeight;
107
+
97
108
  /**
98
109
  * 断点。与 `styles/_responsive.scss` 的 mixin 同源,所以 JS 判断不会和 CSS 媒体查询错开。
99
110
  */