@hsu-react/ui 2.5.2 → 2.5.4

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 (64) hide show
  1. package/es/components/Chart/Bubble/index.js +7 -2
  2. package/es/components/Chart/Common/index.js +7 -2
  3. package/es/components/Chart/Heatmap/index.js +7 -2
  4. package/es/components/Chart/Pie/Pie3D/index.js +8 -2
  5. package/es/components/Chart/Pie/index.js +7 -2
  6. package/es/components/Chat/ChatInput/index.js +7 -2
  7. package/es/components/Icon/index.js +8 -2
  8. package/es/components/Input/Number/index.js +28 -21
  9. package/es/components/Input/Password/index.js +44 -36
  10. package/es/components/Input/Search/index.js +44 -36
  11. package/es/components/Input/TextArea/index.js +64 -42
  12. package/es/components/Input/index.js +40 -34
  13. package/es/components/Select/_hooks/useSelectInputOptions.js +8 -2
  14. package/es/components/Slider/index.js +24 -15
  15. package/es/components/Upload/_hooks/useUploadFileList.js +8 -2
  16. package/es/components/Upload/_hooks/useUploadOperations.js +8 -2
  17. package/es/hooks/useLatestRef.d.ts +24 -0
  18. package/es/hooks/useLatestRef.js +29 -0
  19. package/es/layout/Menu/index.js +24 -6
  20. package/es/layout/Menu/index.module.scss +53 -10
  21. package/lib/components/Chart/Bubble/index.js +6 -2
  22. package/lib/components/Chart/Common/index.js +6 -2
  23. package/lib/components/Chart/Heatmap/index.js +6 -2
  24. package/lib/components/Chart/Pie/Pie3D/index.js +7 -2
  25. package/lib/components/Chart/Pie/index.js +6 -2
  26. package/lib/components/Chat/ChatInput/index.js +6 -2
  27. package/lib/components/Icon/index.js +7 -2
  28. package/lib/components/Input/Number/index.js +27 -18
  29. package/lib/components/Input/Password/index.js +40 -26
  30. package/lib/components/Input/Search/index.js +42 -26
  31. package/lib/components/Input/TextArea/index.js +54 -26
  32. package/lib/components/Input/index.js +37 -24
  33. package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
  34. package/lib/components/Slider/index.js +23 -11
  35. package/lib/components/Upload/_hooks/useUploadFileList.js +7 -2
  36. package/lib/components/Upload/_hooks/useUploadOperations.js +7 -2
  37. package/lib/hooks/useLatestRef.d.ts +24 -0
  38. package/lib/hooks/useLatestRef.js +35 -0
  39. package/lib/layout/Menu/index.js +24 -6
  40. package/lib/layout/Menu/index.module.scss +53 -10
  41. package/package.json +9 -3
  42. package/src/__tests__/setup.ts +26 -0
  43. package/src/components/Chart/Bubble/index.tsx +7 -2
  44. package/src/components/Chart/Common/index.tsx +7 -2
  45. package/src/components/Chart/Heatmap/index.tsx +7 -2
  46. package/src/components/Chart/Pie/Pie3D/index.tsx +7 -2
  47. package/src/components/Chart/Pie/index.tsx +7 -2
  48. package/src/components/Chat/ChatInput/index.tsx +7 -2
  49. package/src/components/Icon/index.tsx +7 -2
  50. package/src/components/Input/Number/index.tsx +25 -23
  51. package/src/components/Input/Password/index.tsx +37 -26
  52. package/src/components/Input/Search/index.tsx +39 -26
  53. package/src/components/Input/TextArea/index.test.tsx +208 -0
  54. package/src/components/Input/TextArea/index.tsx +56 -30
  55. package/src/components/Input/index.test.tsx +189 -0
  56. package/src/components/Input/index.tsx +36 -29
  57. package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
  58. package/src/components/Slider/index.tsx +22 -12
  59. package/src/components/Upload/_hooks/useUploadFileList.ts +6 -2
  60. package/src/components/Upload/_hooks/useUploadOperations.ts +7 -2
  61. package/src/hooks/useLatestRef.ts +31 -0
  62. package/src/layout/Menu/index.md +11 -0
  63. package/src/layout/Menu/index.module.scss +53 -10
  64. package/src/layout/Menu/index.tsx +24 -7
@@ -13,7 +13,7 @@ var _TextArea = _interopRequireDefault(require("./TextArea"));
13
13
  var _classnames = _interopRequireDefault(require("classnames"));
14
14
  var _indexModule = _interopRequireDefault(require("./index.module.scss"));
15
15
  var _Range = _interopRequireDefault(require("./Range"));
16
- var _ahooks = require("ahooks");
16
+ var _useLatestRef = require("../../hooks/useLatestRef");
17
17
  var _jsxRuntime = require("react/jsx-runtime");
18
18
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
19
19
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
@@ -34,8 +34,11 @@ const Input = props => {
34
34
  escapeCharacters,
35
35
  ...inputConfig
36
36
  } = props;
37
- const [isComposing, setComposing] = (0, _react.useState)(false);
38
37
  const ref = (0, _react.useRef)(null);
38
+ const getRefRef = (0, _useLatestRef.useLatestRef)(getRef);
39
+ // 输入法组字期间不往外通知。放 ref 不放 state:它只决定「要不要发通知」,
40
+ // 不参与渲染,而且必须在同一次事件里立刻生效
41
+ const composingRef = (0, _react.useRef)(false);
39
42
 
40
43
  // Handle escaping: if the value contains characters listed in escapeCharacters, prefix them with an escape
41
44
  const escapeValue = (0, _react.useCallback)(val => {
@@ -104,20 +107,23 @@ const Input = props => {
104
107
  };
105
108
  const initialValue = getInitialValue();
106
109
  const [_value, setValue] = (0, _react.useState)(initialValue);
107
- const [lastValue, setLastValue] = (0, _react.useState)(initialValue);
108
110
  const prevValueRef = (0, _react.useRef)(undefined);
109
- (0, _ahooks.useDebounceEffect)(() => {
110
- if (!isComposing && _value !== lastValue) {
111
- const trimmedValue = _value.trim();
112
- const finalValue = trimmedValue === "" ? "" : _value;
113
- setLastValue(finalValue);
114
- // If escapeCharacters is set and the value matches, return the escaped value
115
- const escapedValue = escapeValue(finalValue);
116
- onChange?.(escapedValue);
117
- }
118
- }, [_value, isComposing, lastValue, onChange, escapeValue], {
119
- wait: 10
120
- });
111
+ // 最近一次通知出去的原文(未转义)。只用来去掉 compositionend 与 input
112
+ // 两个事件的重复通知
113
+ const notifiedRef = (0, _react.useRef)(initialValue);
114
+
115
+ /**
116
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
117
+ * 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
118
+ * 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
119
+ * 会让传内联箭头的消费方死循环。
120
+ */
121
+ const notify = next => {
122
+ if (next === notifiedRef.current) return;
123
+ notifiedRef.current = next;
124
+ // 全是空白等同于空;escapeCharacters 命中时对外给转义后的值
125
+ onChange?.(escapeValue(next.trim() === "" ? "" : next));
126
+ };
121
127
  (0, _react.useEffect)(() => {
122
128
  // Update internal state only when the external value prop actually changes
123
129
  if (prevValueRef.current !== value) {
@@ -126,18 +132,21 @@ const Input = props => {
126
132
  const rawValue = typeof value === "number" ? `${value}` : value?.toString();
127
133
  // Unescape before displaying
128
134
  const newValue = unescapeValue(rawValue);
135
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
136
+ notifiedRef.current = newValue;
129
137
  setValue(newValue);
130
- setLastValue(newValue);
131
138
  } else {
132
139
  // Clear only on initialization or when explicitly set to undefined externally
140
+ notifiedRef.current = "";
133
141
  setValue("");
134
- setLastValue("");
135
142
  }
136
143
  }
137
144
  }, [value, unescapeValue]);
145
+
146
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
138
147
  (0, _react.useEffect)(() => {
139
- getRef?.(ref.current);
140
- }, [getRef]);
148
+ getRefRef.current?.(ref.current);
149
+ }, [getRefRef]);
141
150
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_antd.Tooltip, {
142
151
  placement: "topLeft",
143
152
  ...tooltip,
@@ -145,11 +154,14 @@ const Input = props => {
145
154
  ref: ref,
146
155
  disabled: disabled,
147
156
  placeholder: disabled ? "" : placeholder,
148
- onCompositionStart: () => setComposing(true),
149
- onCompositionEnd: () => {
150
- setTimeout(() => {
151
- setComposing(false);
152
- }, 1);
157
+ onCompositionStart: () => {
158
+ composingRef.current = true;
159
+ },
160
+ onCompositionEnd: e => {
161
+ composingRef.current = false;
162
+ // 组完字的那一下自己发通知。浏览器之间 compositionend 与 input 的先后
163
+ // 不一致,`notify` 里按原文去重,两种顺序都只会发出一次
164
+ notify(unescapeValue(e.currentTarget.value));
153
165
  },
154
166
  value: _value,
155
167
  onChange: e => {
@@ -157,6 +169,7 @@ const Input = props => {
157
169
  const inputValue = e.target.value;
158
170
  const unescapedInput = unescapeValue(inputValue);
159
171
  setValue(unescapedInput);
172
+ if (!composingRef.current) notify(unescapedInput);
160
173
  },
161
174
  className: (0, _classnames.default)(_indexModule.default.antdInput, className),
162
175
  type: type,
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useSelectInputOptions = useSelectInputOptions;
7
7
  var _react = require("react");
8
+ var _useLatestRef = require("../../../hooks/useLatestRef");
8
9
  /**
9
10
  * Manage input options (when there are no matching options)
10
11
  */
@@ -16,11 +17,15 @@ function useSelectInputOptions({
16
17
  optionsLength
17
18
  }) {
18
19
  const [inputOptions, setInputOptions] = (0, _react.useState)([]);
20
+ const onSearchRef = (0, _useLatestRef.useLatestRef)(onSearch);
21
+
22
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
23
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
19
24
  (0, _react.useEffect)(() => {
20
25
  if (!isComposing) {
21
- onSearch?.(searchValue);
26
+ onSearchRef.current?.(searchValue);
22
27
  }
23
- }, [inputOptions, isComposing, onChange, onSearch, optionsLength, searchValue]);
28
+ }, [inputOptions, isComposing, onSearchRef, optionsLength, searchValue]);
24
29
  return {
25
30
  inputOptions,
26
31
  setInputOptions
@@ -21,25 +21,37 @@ const Slider = props => {
21
21
  ...sliderConfig
22
22
  } = props;
23
23
  const [_value, setValue] = (0, _react.useState)(value);
24
- const [lastValue, setLastValue] = (0, _react.useState)(value);
25
- (0, _react.useEffect)(() => {
26
- if (_value !== lastValue) {
27
- setLastValue(_value);
28
- onChange?.(_value);
29
- }
30
- }, [lastValue, onChange, _value]);
24
+ // 最近一次通知出去的值,代替原来那个用 state 记账的写法
25
+ const notifiedRef = (0, _react.useRef)(value);
26
+
27
+ /**
28
+ * `onChange` 是「用户拖了滑块」这个**事件**的通知,不是从 state 推导出来的结果,
29
+ * 所以在事件处理里发,不在 effect 里发。详见 `Input/TextArea/index.tsx` 里那段
30
+ * 说明——旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
31
+ * 会让传内联箭头的消费方死循环。
32
+ */
33
+ const handleChange = next => {
34
+ setValue(next);
35
+ if (next === notifiedRef.current) return;
36
+ notifiedRef.current = next;
37
+ onChange?.(next);
38
+ };
39
+
40
+ // 外部值(`value` / `topValue`)变了才回写,不跟着通知走
31
41
  (0, _react.useEffect)(() => {
32
- if ((topValue || value) !== lastValue) {
33
- setValue(topValue || value);
42
+ const next = topValue || value;
43
+ if (next !== notifiedRef.current) {
44
+ notifiedRef.current = next;
45
+ setValue(next);
34
46
  }
35
- }, [lastValue, topValue, value]);
47
+ }, [topValue, value]);
36
48
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_antd.Slider, {
37
49
  className: (0, _classnames.default)([_indexModule.default.slider, className]),
38
50
  tooltip: {
39
51
  open: false
40
52
  },
41
53
  value: _value,
42
- onChange: setValue,
54
+ onChange: handleChange,
43
55
  ...sliderConfig
44
56
  });
45
57
  };
@@ -7,6 +7,7 @@ exports.useUploadFileList = useUploadFileList;
7
7
  var _react = require("react");
8
8
  var _hsuUtils = require("hsu-utils");
9
9
  var _utils = require("../_utils");
10
+ var _useLatestRef = require("../../../hooks/useLatestRef");
10
11
  /**
11
12
  * Manage the upload file list state
12
13
  */
@@ -17,6 +18,10 @@ function useUploadFileList({
17
18
  }) {
18
19
  const [_fileList, setFilelist] = (0, _react.useState)([]);
19
20
  const [lastFileList, setLastFileList] = (0, _react.useState)([]);
21
+ const onChangeRef = (0, _useLatestRef.useLatestRef)(onChange);
22
+
23
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
24
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
20
25
  (0, _react.useEffect)(() => {
21
26
  if (rmFile) {
22
27
  const file = _fileList.find(item => item.uid === rmFile);
@@ -24,14 +29,14 @@ function useUploadFileList({
24
29
  if (file) {
25
30
  setFilelist(filteredList);
26
31
  setTimeout(() => {
27
- onChange?.({
32
+ onChangeRef.current?.({
28
33
  file,
29
34
  fileList: filteredList
30
35
  });
31
36
  }, 100);
32
37
  }
33
38
  }
34
- }, [_fileList, onChange, rmFile]);
39
+ }, [_fileList, onChangeRef, rmFile]);
35
40
  (0, _react.useEffect)(() => {
36
41
  if (!_hsuUtils.Equal.ObjEqual(fileList ?? [], lastFileList)) {
37
42
  if (fileList?.length) {
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.useUploadOperations = useUploadOperations;
7
7
  var _react = require("react");
8
8
  var _hsuUtils = require("hsu-utils");
9
+ var _useLatestRef = require("../../../hooks/useLatestRef");
9
10
  /**
10
11
  * Manage upload and download operations
11
12
  */
@@ -19,6 +20,7 @@ function useUploadOperations({
19
20
  downloading: {},
20
21
  uploadingList: {}
21
22
  });
23
+ const onUploadingListRef = (0, _useLatestRef.useLatestRef)(onUploadingList);
22
24
 
23
25
  // Sync to the ref on update
24
26
  (0, _react.useEffect)(() => {
@@ -27,12 +29,15 @@ function useUploadOperations({
27
29
  (0, _react.useEffect)(() => {
28
30
  operationsRef.current.uploadingList = uploadingList;
29
31
  }, [uploadingList]);
32
+
33
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
34
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
30
35
  (0, _react.useEffect)(() => {
31
36
  if (!_hsuUtils.Equal.ObjEqual(uploadingList, lastUploadList)) {
32
- onUploadingList?.((0, _hsuUtils.deepCopy)(uploadingList));
37
+ onUploadingListRef.current?.((0, _hsuUtils.deepCopy)(uploadingList));
33
38
  setLastUploadList((0, _hsuUtils.deepCopy)(uploadingList));
34
39
  }
35
- }, [uploadingList, lastUploadList, onUploadingList]);
40
+ }, [uploadingList, lastUploadList, onUploadingListRef]);
36
41
 
37
42
  // Cleanup function
38
43
  (0, _react.useEffect)(() => {
@@ -0,0 +1,24 @@
1
+ /// <reference types="react" />
2
+ /**
3
+ * 把一个每次渲染都可能换引用的值(通常是回调 prop)装进一个**引用恒定**的 ref。
4
+ *
5
+ * 用途只有一个:让 effect 不必把回调放进依赖数组。
6
+ * 回调放进依赖数组、effect 体内又调它,是一条会让消费方死循环的写法——
7
+ * 消费方传内联箭头(`onChange={(v) => setX(v)}`,React 里最常见的写法)时
8
+ * 每次渲染都是新引用 → effect 重跑 → 调回调 → 父级 setState → 再渲染 →
9
+ * 又是新引用 …… 直到 React 抛 `Maximum update depth exceeded`。
10
+ *
11
+ * ```ts
12
+ * const onDoneRef = useLatestRef(onDone);
13
+ * useEffect(() => {
14
+ * const t = setTimeout(() => onDoneRef.current?.(), 1000);
15
+ * return () => clearTimeout(t);
16
+ * }, [onDoneRef]); // 只在挂载时跑一次,且永远调到最新的 onDone
17
+ * ```
18
+ *
19
+ * 注意:渲染期间就地赋值。React 官方对 ref 的约束是「不要在渲染中**读**可变值」,
20
+ * 写入最新的 props 是 useEffectEvent 落地前的通行做法(ahooks 的 `useLatest`、
21
+ * React 文档里的 `useEvent` polyfill 都是这么写的)。
22
+ */
23
+ export declare function useLatestRef<T>(value: T): import("react").MutableRefObject<T>;
24
+ export default useLatestRef;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ exports.useLatestRef = useLatestRef;
8
+ var _react = require("react");
9
+ /**
10
+ * 把一个每次渲染都可能换引用的值(通常是回调 prop)装进一个**引用恒定**的 ref。
11
+ *
12
+ * 用途只有一个:让 effect 不必把回调放进依赖数组。
13
+ * 回调放进依赖数组、effect 体内又调它,是一条会让消费方死循环的写法——
14
+ * 消费方传内联箭头(`onChange={(v) => setX(v)}`,React 里最常见的写法)时
15
+ * 每次渲染都是新引用 → effect 重跑 → 调回调 → 父级 setState → 再渲染 →
16
+ * 又是新引用 …… 直到 React 抛 `Maximum update depth exceeded`。
17
+ *
18
+ * ```ts
19
+ * const onDoneRef = useLatestRef(onDone);
20
+ * useEffect(() => {
21
+ * const t = setTimeout(() => onDoneRef.current?.(), 1000);
22
+ * return () => clearTimeout(t);
23
+ * }, [onDoneRef]); // 只在挂载时跑一次,且永远调到最新的 onDone
24
+ * ```
25
+ *
26
+ * 注意:渲染期间就地赋值。React 官方对 ref 的约束是「不要在渲染中**读**可变值」,
27
+ * 写入最新的 props 是 useEffectEvent 落地前的通行做法(ahooks 的 `useLatest`、
28
+ * React 文档里的 `useEvent` polyfill 都是这么写的)。
29
+ */
30
+ function useLatestRef(value) {
31
+ const ref = (0, _react.useRef)(value);
32
+ ref.current = value;
33
+ return ref;
34
+ }
35
+ var _default = exports.default = useLatestRef;
@@ -147,12 +147,30 @@ const Menu = props => {
147
147
  });
148
148
 
149
149
  // 次级菜单默认带一段头部(至少给出返回入口),调用方可用 secondaryHeader 整块换掉
150
- return secondary ? /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
151
- className: _indexModule.default.secondaryWrap,
152
- children: [secondaryHeader ?? /*#__PURE__*/(0, _jsxRuntime.jsx)(_SecondaryHeader.default, {
153
- collapsed: collapsed,
154
- theme: theme
155
- }), menu]
150
+ if (secondary) {
151
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
152
+ className: (0, _classnames.default)(_indexModule.default.scrollHost, _indexModule.default.secondaryWrap),
153
+ children: [secondaryHeader ?? /*#__PURE__*/(0, _jsxRuntime.jsx)(_SecondaryHeader.default, {
154
+ collapsed: collapsed,
155
+ theme: theme
156
+ }), menu]
157
+ });
158
+ }
159
+
160
+ /**
161
+ * 侧栏(inline)菜单自带滚动宿主:宿主不滚、菜单根滚。
162
+ *
163
+ * 不这么做的话「谁滚」就落在消费方的 Sider 上 —— 而 antd 的
164
+ * `.ant-layout-sider` / `.ant-layout-sider-children` 都没有 overflow,
165
+ * 展开子菜单的动画期间高度先超出再收回,滚动条会闪一下。几何契约必须由组件
166
+ * 自己闭合,不能指望每个消费方都补一遍 overflow 规则。
167
+ *
168
+ * 横向菜单不需要(rc-overflow 自己收成 “…”),也不能加:那层 div 会把
169
+ * `.menu` 从 Header 的 flex 行里挪走。
170
+ */
171
+ return mode === "inline" ? /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
172
+ className: _indexModule.default.scrollHost,
173
+ children: menu
156
174
  }) : menu;
157
175
  };
158
176
  var _default = exports.default = Menu;
@@ -1,13 +1,6 @@
1
1
  // 注意:CSS Module 里的类名都会被哈希,antd 的类名必须写在 :global() 中,否则
2
2
  // `.ant-menu-horizontal` 会变成 `.ant-menu-horizontal___xxxxx`,静悄悄地永不生效。
3
3
  .menu {
4
- &::-webkit-scrollbar {
5
- width: 0px;
6
- height: 0px;
7
- }
8
-
9
- scrollbar-width: none;
10
-
11
4
  // 顶部横向菜单:占满剩余宽度,背景交给外层的导航渐变
12
5
  &:global(.ant-menu-horizontal) {
13
6
  flex: 1;
@@ -15,6 +8,50 @@
15
8
 
16
9
  background-color: transparent;
17
10
  }
11
+
12
+ /* ===== inline(侧栏):菜单根**就是**那个滚动容器 =====
13
+ antd 的 `.ant-layout-sider` / `.ant-layout-sider-children` 都没有设 overflow,
14
+ 所以在这条路径上,「谁滚」原本是没人规定的 —— 展开子菜单那 200ms 里
15
+ rc-motion 会逐帧改高度,容器先超出再收回,滚动条由**当时**恰好溢出的那个
16
+ 元素临时长出来,于是闪一下。
17
+
18
+ 这里把它定死:菜单根 `flex: 1 / min-height: 0 / overflow-y: auto`,配上外面
19
+ 那层不滚的 `.scrollHost`,中间态怎么抖都轮不到别人显示滚动条;菜单长过一屏
20
+ 时也仍然滚得到底部。 */
21
+ &:global(.ant-menu-inline),
22
+ &:global(.ant-menu-inline-collapsed) {
23
+ flex: 1;
24
+ min-height: 0px;
25
+
26
+ // 横向一律不滚:菜单文字过长时该省略,不该把侧栏推出一条横向滚动条
27
+ overflow-x: hidden;
28
+ overflow-y: auto;
29
+ }
30
+
31
+ /* 子菜单在展开 / 收起动画期间被 rc-motion 逐帧改高度。antd 的
32
+ `.ant-motion-collapse` 自带 overflow hidden,这里补的是**动画结束、类名摘掉
33
+ 之后**那一帧:此时 ul 的高度刚回到 auto,若父级恰好差几像素,浏览器会先画出
34
+ 滚动条再重排。子菜单本来也永远不需要自己滚。 */
35
+ :global(.ant-menu-sub.ant-menu-inline) {
36
+ overflow: hidden;
37
+ }
38
+
39
+ /* ===== 滚动条外观:本组件不管,交给消费方 =====
40
+ 这里曾经写着 `scrollbar-width: none` + `::-webkit-scrollbar { width: 0 }`,
41
+ 用「把滚动条永久藏掉」来遮上面那个闪动。现在闪动由 overflow 归属治本了,
42
+ 这份「藏」不但多余,还有两处害处,所以整段删掉:
43
+
44
+ 1. **Chrome 121+ 一旦读到 `scrollbar-width` / `scrollbar-color` 的非默认值,
45
+ 就会整套忽略该元素上的 `::-webkit-scrollbar` 规则。** 库里无差别写下一句
46
+ `scrollbar-width: none`,等于在每个消费方的侧栏上悄悄废掉他们全站那套
47
+ 滚动条皮肤,只剩系统细条 —— 而且不报错、很难查。
48
+ (所以本库任何地方要写这两个属性,都必须锁在
49
+ `@supports (-moz-appearance: none)` 里,让 Chrome 读不到。)
50
+ 2. 滚动条的「显 / 隐 / 配色」是应用级的设计语言(有的应用要常驻,有的要
51
+ 悬浮才显现),不是菜单组件的行为。库只该规定**谁滚**,不该用高权重规则
52
+ 把可见性堵死、逼消费方回过头来盖。
53
+
54
+ 消费方在全局写 `::-webkit-scrollbar` 那一套即可,会自然落到菜单根上。 */
18
55
  }
19
56
 
20
57
  // 弹出的子菜单挂在 body 上,够不到 .menu,所以由组件把这个类挂到 popup 上
@@ -37,14 +74,20 @@
37
74
  }
38
75
  }
39
76
 
40
- // 次级菜单:头部固定在上,菜单撑满剩余高度
41
- .secondaryWrap {
77
+ /* inline 菜单的滚动宿主:撑满侧栏高度、自己**不滚**,把高度整份让给菜单根。
78
+ 有它在,菜单组件才是自带滚动容器语义的一整块 —— 消费方把 Menu 塞进任何一个
79
+ 有高度的容器(antd 的 Sider、或自己的 div)都不必再补 overflow 规则。 */
80
+ .scrollHost {
42
81
  height: 100%;
43
82
  display: flex;
44
83
  flex-direction: column;
45
84
 
85
+ overflow: hidden;
86
+ }
87
+
88
+ // 次级菜单:头部固定在上,菜单撑满剩余高度(滚动归属见 .scrollHost)
89
+ .secondaryWrap {
46
90
  :global(.ant-menu) {
47
- flex: 1;
48
91
  border-inline-end: none !important;
49
92
  }
50
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hsu-react/ui",
3
- "version": "2.5.2",
3
+ "version": "2.5.4",
4
4
  "description": "一套基于 React + Ant Design 的中后台业务组件库",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,9 @@
42
42
  "docs:build": "dumi build",
43
43
  "docs:preview": "dumi preview",
44
44
  "build": "node scripts/clean-dist.cjs && node scripts/gen-tokens-scss.cjs && father build && node scripts/mark-cjs.cjs && node scripts/check-heavy-deps.cjs",
45
- "prepublishOnly": "node scripts/clean-dist.cjs && node scripts/gen-tokens-scss.cjs --check && father build && node scripts/mark-cjs.cjs && node scripts/check-heavy-deps.cjs",
45
+ "prepublishOnly": "node scripts/clean-dist.cjs && node scripts/gen-tokens-scss.cjs --check && vitest run && father build && node scripts/mark-cjs.cjs && node scripts/check-heavy-deps.cjs",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
46
48
  "check:deps": "node scripts/check-heavy-deps.cjs",
47
49
  "tokens": "node scripts/gen-tokens-scss.cjs",
48
50
  "check:tokens": "node scripts/gen-tokens-scss.cjs --check"
@@ -115,6 +117,8 @@
115
117
  },
116
118
  "devDependencies": {
117
119
  "@ant-design/icons": "^6.3.2",
120
+ "@testing-library/dom": "^10",
121
+ "@testing-library/react": "^16",
118
122
  "@types/js-cookie": "^3.0.6",
119
123
  "@types/lodash": "^4.17.4",
120
124
  "@types/markdown-it": "^14.1.2",
@@ -126,6 +130,7 @@
126
130
  "antd": "6.5.4",
127
131
  "dumi": "^2.4.0",
128
132
  "father": "^4.5.0",
133
+ "jsdom": "^25",
129
134
  "mobx": "^6.12.3",
130
135
  "mobx-react-lite": "^4.0.7",
131
136
  "react": "^18.3.1",
@@ -134,7 +139,8 @@
134
139
  "react-router": "^6.23.1",
135
140
  "react-router-dom": "^6.23.1",
136
141
  "sass": "^1.101.0",
137
- "typescript": "^5.4.5"
142
+ "typescript": "^5.4.5",
143
+ "vitest": "^2"
138
144
  },
139
145
  "peerDependenciesMeta": {
140
146
  "react-router": {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * jsdom 没有 `matchMedia` / `ResizeObserver`,antd 与 rc-* 会直接调它们。
3
+ * 这里补齐,缺一个就整个测试文件起不来
4
+ */
5
+ import "@testing-library/react";
6
+
7
+ if (!window.matchMedia) {
8
+ window.matchMedia = ((query: string) => ({
9
+ matches: false,
10
+ media: query,
11
+ onchange: null,
12
+ addListener: () => {},
13
+ removeListener: () => {},
14
+ addEventListener: () => {},
15
+ removeEventListener: () => {},
16
+ dispatchEvent: () => false,
17
+ })) as unknown as typeof window.matchMedia;
18
+ }
19
+
20
+ if (!(globalThis as { ResizeObserver?: unknown }).ResizeObserver) {
21
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
22
+ observe() {}
23
+ unobserve() {}
24
+ disconnect() {}
25
+ };
26
+ }
@@ -12,6 +12,7 @@ import {
12
12
  defaultBubbleColorList,
13
13
  drawCircles,
14
14
  } from "../_utils/bubble";
15
+ import { useLatestRef } from "../../../hooks/useLatestRef";
15
16
 
16
17
  export interface BubbleDataItem {
17
18
  name: string;
@@ -370,6 +371,10 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
370
371
  }
371
372
  }, [chartOption, data]);
372
373
 
374
+ const onChartRef = useLatestRef(onChart);
375
+
376
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
377
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
373
378
  // Initialize the chart
374
379
  useEffect(() => {
375
380
  if (!chartRef.current) return;
@@ -382,7 +387,7 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
382
387
  chart = echarts.init(chartRef.current);
383
388
  chartInstanceRef.current = chart;
384
389
  // Call the onChart callback on first initialization
385
- onChart?.(chart);
390
+ onChartRef.current?.(chart);
386
391
  }
387
392
 
388
393
  // Resize first so echarts picks up the correct container size, then apply the option
@@ -410,7 +415,7 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
410
415
  containerSize.width,
411
416
  containerSize.height,
412
417
  handleResize,
413
- onChart,
418
+ onChartRef,
414
419
  onClick,
415
420
  ]);
416
421
 
@@ -3,6 +3,7 @@ import useContainerReady from "../_hooks/useContainerReady";
3
3
  import styles from "../index.module.scss";
4
4
  import { ChartCommonProps, ChartOptionType, ChartsOption } from "..";
5
5
  import * as echarts from "echarts";
6
+ import { useLatestRef } from "../../../hooks/useLatestRef";
6
7
 
7
8
  const Common: React.FC<ChartCommonProps> = (props) => {
8
9
  const { className, style, onChart, ...coreOption } = props;
@@ -31,6 +32,10 @@ const Common: React.FC<ChartCommonProps> = (props) => {
31
32
  chartInstanceRef.current?.resize();
32
33
  }, []);
33
34
 
35
+ const onChartRef = useLatestRef(onChart);
36
+
37
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
38
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
34
39
  // Initialize the chart
35
40
  useEffect(() => {
36
41
  if (!chartRef.current || !containerReady) return;
@@ -46,7 +51,7 @@ const Common: React.FC<ChartCommonProps> = (props) => {
46
51
  chart.setOption(chartOption as ChartOptionType, true);
47
52
 
48
53
  // Chart-ready callback (can be used for legend auto-scroll, etc.)
49
- onChart?.(chart);
54
+ onChartRef.current?.(chart);
50
55
 
51
56
  // Add resize listener
52
57
  window.addEventListener("resize", handleResize);
@@ -61,7 +66,7 @@ const Common: React.FC<ChartCommonProps> = (props) => {
61
66
  return () => {
62
67
  window.removeEventListener("resize", handleResize);
63
68
  };
64
- }, [chartOption, handleResize, onChart,
69
+ }, [chartOption, handleResize, onChartRef,
65
70
  containerReady,
66
71
  ]);
67
72
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "../_utils/heatmap";
10
10
  import { resolveChartChrome } from "../_utils/chartTheme";
11
11
  import useIsDark from "../../../hooks/useIsDark";
12
+ import { useLatestRef } from "../../../hooks/useLatestRef";
12
13
 
13
14
  export interface HeatmapDataItem {
14
15
  /** X-axis index or name */
@@ -266,6 +267,10 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
266
267
  chartInstanceRef.current?.resize();
267
268
  }, []);
268
269
 
270
+ const onChartRef = useLatestRef(onChart);
271
+
272
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
273
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
269
274
  // Initialize the chart
270
275
  useEffect(() => {
271
276
  if (!chartRef.current || !containerReady) return;
@@ -276,7 +281,7 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
276
281
  chart = echarts.init(chartRef.current);
277
282
  chartInstanceRef.current = chart;
278
283
  // Call the onChart callback on first initialization
279
- onChart?.(chart);
284
+ onChartRef.current?.(chart);
280
285
  }
281
286
 
282
287
  // Apply the option
@@ -304,7 +309,7 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
304
309
  chartInstanceRef.current?.off("click", onClick);
305
310
  }
306
311
  };
307
- }, [chartOption, handleResize, onChart, onClick,
312
+ }, [chartOption, handleResize, onChartRef, onClick,
308
313
  containerReady,
309
314
  ]);
310
315