@hsu-react/ui 2.5.1 → 2.5.3

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.
@@ -184,12 +184,30 @@ var Menu = function Menu(props) {
184
184
  }));
185
185
 
186
186
  // 次级菜单默认带一段头部(至少给出返回入口),调用方可用 secondaryHeader 整块换掉
187
- return secondary ? /*#__PURE__*/_jsxs("div", {
188
- className: styles.secondaryWrap,
189
- children: [secondaryHeader !== null && secondaryHeader !== void 0 ? secondaryHeader : /*#__PURE__*/_jsx(SecondaryHeader, {
190
- collapsed: collapsed,
191
- theme: theme
192
- }), menu]
187
+ if (secondary) {
188
+ return /*#__PURE__*/_jsxs("div", {
189
+ className: classNames(styles.scrollHost, styles.secondaryWrap),
190
+ children: [secondaryHeader !== null && secondaryHeader !== void 0 ? secondaryHeader : /*#__PURE__*/_jsx(SecondaryHeader, {
191
+ collapsed: collapsed,
192
+ theme: theme
193
+ }), menu]
194
+ });
195
+ }
196
+
197
+ /**
198
+ * 侧栏(inline)菜单自带滚动宿主:宿主不滚、菜单根滚。
199
+ *
200
+ * 不这么做的话「谁滚」就落在消费方的 Sider 上 —— 而 antd 的
201
+ * `.ant-layout-sider` / `.ant-layout-sider-children` 都没有 overflow,
202
+ * 展开子菜单的动画期间高度先超出再收回,滚动条会闪一下。几何契约必须由组件
203
+ * 自己闭合,不能指望每个消费方都补一遍 overflow 规则。
204
+ *
205
+ * 横向菜单不需要(rc-overflow 自己收成 “…”),也不能加:那层 div 会把
206
+ * `.menu` 从 Header 的 flex 行里挪走。
207
+ */
208
+ return mode === "inline" ? /*#__PURE__*/_jsx("div", {
209
+ className: styles.scrollHost,
210
+ children: menu
193
211
  }) : menu;
194
212
  };
195
213
  export 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
  }
@@ -143,22 +143,29 @@ export var useTabPath = function useTabPath(_ref) {
143
143
  }, [location.pathname, location.search]);
144
144
 
145
145
  /**
146
- * Closing the last tab navigates to `basePath` — but when that tab *was* `basePath`, the
147
- * location never changes, so path matching would not re-run and the bar would be left empty
148
- * while its page is still on screen. Going empty has to re-trigger the match on its own.
146
+ * 路径匹配只跟着**地址**跑,不跟着 openKeys 的空/非空跑。
149
147
  *
150
- * This settles rather than looping: re-matching adds the tab back, which flips `isEmpty` and
151
- * runs the effect once more, and that pass finds the tab already present and returns the same
152
- * state object — so React stops there. (Only true because the setters below bail out when
153
- * nothing changed; without that this would spin.)
148
+ * 这里原本还挂了一个 `isEmpty` 依赖:openKeys 一变空就重跑一次匹配,用意是补上
149
+ * 「关掉的正是 basePath 那一页 navigate(basePath) 不改变地址 匹配不会重跑」
150
+ * 这一档。但它会把**刚关掉的那个页签原样加回来**:
151
+ *
152
+ * 关闭按钮里 `setOpenkeys([])` 是默认优先级的更新,而 `navigate()` 引起的
153
+ * location 变更走的是 router 的低优先级更新(startTransition / deferred 路由表)。
154
+ * React 先提交前者,此时 `useLocation()` 还停在**被关掉的那一页**,本 effect 因
155
+ * `isEmpty` 翻转而跑了一遍,匹配到的自然还是它,于是页签复活;等 location 真正
156
+ * 落地,匹配又把目标页加成第二个页签。用户看到的就是「点一次关不掉,还多出一个」,
157
+ * 非得再点一次。
158
+ *
159
+ * 所以不补那一档:**页签栏空着是合法状态**。关掉最后一个页签时,若目标地址与当前
160
+ * 地址不同,location 变化自会把目标页签匹配出来;若相同(关掉的就是 basePath),
161
+ * 页签栏就空着,内容区仍是 basePath 那一页 —— 一次点击、不复活、不空白。
154
162
  */
155
- var isEmpty = openKeys.length === 0;
156
163
  useEffect(function () {
157
164
  // Handle affixed routes first to populate openKeys
158
165
  _checkAffix(items);
159
166
  // Then run path matching
160
167
  _checkPath(items);
161
- }, [_checkAffix, _checkPath, items, isEmpty]);
168
+ }, [_checkAffix, _checkPath, items]);
162
169
  return {
163
170
  tabKey: tabKey,
164
171
  setTabKey: setTabKey,
@@ -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
  }
@@ -116,22 +116,29 @@ const useTabPath = ({
116
116
  }, [location.pathname, location.search]);
117
117
 
118
118
  /**
119
- * Closing the last tab navigates to `basePath` — but when that tab *was* `basePath`, the
120
- * location never changes, so path matching would not re-run and the bar would be left empty
121
- * while its page is still on screen. Going empty has to re-trigger the match on its own.
119
+ * 路径匹配只跟着**地址**跑,不跟着 openKeys 的空/非空跑。
122
120
  *
123
- * This settles rather than looping: re-matching adds the tab back, which flips `isEmpty` and
124
- * runs the effect once more, and that pass finds the tab already present and returns the same
125
- * state object — so React stops there. (Only true because the setters below bail out when
126
- * nothing changed; without that this would spin.)
121
+ * 这里原本还挂了一个 `isEmpty` 依赖:openKeys 一变空就重跑一次匹配,用意是补上
122
+ * 「关掉的正是 basePath 那一页 navigate(basePath) 不改变地址 匹配不会重跑」
123
+ * 这一档。但它会把**刚关掉的那个页签原样加回来**:
124
+ *
125
+ * 关闭按钮里 `setOpenkeys([])` 是默认优先级的更新,而 `navigate()` 引起的
126
+ * location 变更走的是 router 的低优先级更新(startTransition / deferred 路由表)。
127
+ * React 先提交前者,此时 `useLocation()` 还停在**被关掉的那一页**,本 effect 因
128
+ * `isEmpty` 翻转而跑了一遍,匹配到的自然还是它,于是页签复活;等 location 真正
129
+ * 落地,匹配又把目标页加成第二个页签。用户看到的就是「点一次关不掉,还多出一个」,
130
+ * 非得再点一次。
131
+ *
132
+ * 所以不补那一档:**页签栏空着是合法状态**。关掉最后一个页签时,若目标地址与当前
133
+ * 地址不同,location 变化自会把目标页签匹配出来;若相同(关掉的就是 basePath),
134
+ * 页签栏就空着,内容区仍是 basePath 那一页 —— 一次点击、不复活、不空白。
127
135
  */
128
- const isEmpty = openKeys.length === 0;
129
136
  (0, _react.useEffect)(() => {
130
137
  // Handle affixed routes first to populate openKeys
131
138
  _checkAffix(items);
132
139
  // Then run path matching
133
140
  _checkPath(items);
134
- }, [_checkAffix, _checkPath, items, isEmpty]);
141
+ }, [_checkAffix, _checkPath, items]);
135
142
  return {
136
143
  tabKey,
137
144
  setTabKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hsu-react/ui",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "description": "一套基于 React + Ant Design 的中后台业务组件库",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,6 +28,17 @@ import Layout from "@hsu-react/ui/es/layout";
28
28
 
29
29
  `meta.secondary` 标记的路由进入**二级菜单**:主菜单只显示到一级,点进去之后左侧换成该模块的次级菜单,顶部可用 `secondaryHeader` 放返回入口、标题或检索框。
30
30
 
31
+ ### 滚动与滚动条
32
+
33
+ `inline` 模式下本组件**自带滚动容器**:外层包一个撑满高度、自己不滚的宿主,菜单根 `overflow-y: auto` 负责滚。
34
+ 所以把 `Menu` 塞进任何一个**有确定高度**的容器(antd 的 `Layout.Sider`、或自己的 div)即可,不需要再补 `overflow` 规则;
35
+ 展开 / 收起子菜单的动画期间也不会有滚动条闪出来。宿主没有确定高度时菜单会被撑开、退回由页面滚,这时请给宿主一个高度。
36
+
37
+ 滚动条的**外观(显 / 隐 / 配色)本组件不管**,交给消费方的全局样式(`::-webkit-scrollbar` 那一套)。
38
+ 组件里刻意不写 `scrollbar-width` / `scrollbar-color`:**Chrome 121+ 一旦读到它们的非默认值,就会整套忽略该元素上的
39
+ `::-webkit-scrollbar` 规则**,库里无差别写一句就会悄悄废掉消费方全站的滚动条皮肤。库内其它地方若确实需要这两个属性,
40
+ 必须锁在 `@supports (-moz-appearance: none)` 里,只给 Firefox 看。
41
+
31
42
  ## API
32
43
 
33
44
  | 属性 | 说明 | 类型 | 默认值 |
@@ -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
  }
@@ -193,13 +193,30 @@ const Menu: React.FC<MenuProps> = (props) => {
193
193
  );
194
194
 
195
195
  // 次级菜单默认带一段头部(至少给出返回入口),调用方可用 secondaryHeader 整块换掉
196
- return secondary ? (
197
- <div className={styles.secondaryWrap}>
198
- {secondaryHeader ?? (
199
- <SecondaryHeader collapsed={collapsed} theme={theme as "light" | "dark"} />
200
- )}
201
- {menu}
202
- </div>
196
+ if (secondary) {
197
+ return (
198
+ <div className={classNames(styles.scrollHost, styles.secondaryWrap)}>
199
+ {secondaryHeader ?? (
200
+ <SecondaryHeader collapsed={collapsed} theme={theme as "light" | "dark"} />
201
+ )}
202
+ {menu}
203
+ </div>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * 侧栏(inline)菜单自带滚动宿主:宿主不滚、菜单根滚。
209
+ *
210
+ * 不这么做的话「谁滚」就落在消费方的 Sider 上 —— 而 antd 的
211
+ * `.ant-layout-sider` / `.ant-layout-sider-children` 都没有 overflow,
212
+ * 展开子菜单的动画期间高度先超出再收回,滚动条会闪一下。几何契约必须由组件
213
+ * 自己闭合,不能指望每个消费方都补一遍 overflow 规则。
214
+ *
215
+ * 横向菜单不需要(rc-overflow 自己收成 “…”),也不能加:那层 div 会把
216
+ * `.menu` 从 Header 的 flex 行里挪走。
217
+ */
218
+ return mode === "inline" ? (
219
+ <div className={styles.scrollHost}>{menu}</div>
203
220
  ) : (
204
221
  menu
205
222
  );
@@ -129,23 +129,29 @@ export const useTabPath = ({ items, affixRouter }: UseTabPathOptions) => {
129
129
  );
130
130
 
131
131
  /**
132
- * Closing the last tab navigates to `basePath` — but when that tab *was* `basePath`, the
133
- * location never changes, so path matching would not re-run and the bar would be left empty
134
- * while its page is still on screen. Going empty has to re-trigger the match on its own.
132
+ * 路径匹配只跟着**地址**跑,不跟着 openKeys 的空/非空跑。
135
133
  *
136
- * This settles rather than looping: re-matching adds the tab back, which flips `isEmpty` and
137
- * runs the effect once more, and that pass finds the tab already present and returns the same
138
- * state object — so React stops there. (Only true because the setters below bail out when
139
- * nothing changed; without that this would spin.)
134
+ * 这里原本还挂了一个 `isEmpty` 依赖:openKeys 一变空就重跑一次匹配,用意是补上
135
+ * 「关掉的正是 basePath 那一页 navigate(basePath) 不改变地址 匹配不会重跑」
136
+ * 这一档。但它会把**刚关掉的那个页签原样加回来**:
137
+ *
138
+ * 关闭按钮里 `setOpenkeys([])` 是默认优先级的更新,而 `navigate()` 引起的
139
+ * location 变更走的是 router 的低优先级更新(startTransition / deferred 路由表)。
140
+ * React 先提交前者,此时 `useLocation()` 还停在**被关掉的那一页**,本 effect 因
141
+ * `isEmpty` 翻转而跑了一遍,匹配到的自然还是它,于是页签复活;等 location 真正
142
+ * 落地,匹配又把目标页加成第二个页签。用户看到的就是「点一次关不掉,还多出一个」,
143
+ * 非得再点一次。
144
+ *
145
+ * 所以不补那一档:**页签栏空着是合法状态**。关掉最后一个页签时,若目标地址与当前
146
+ * 地址不同,location 变化自会把目标页签匹配出来;若相同(关掉的就是 basePath),
147
+ * 页签栏就空着,内容区仍是 basePath 那一页 —— 一次点击、不复活、不空白。
140
148
  */
141
- const isEmpty = openKeys.length === 0;
142
-
143
149
  useEffect(() => {
144
150
  // Handle affixed routes first to populate openKeys
145
151
  _checkAffix(items);
146
152
  // Then run path matching
147
153
  _checkPath(items);
148
- }, [_checkAffix, _checkPath, items, isEmpty]);
154
+ }, [_checkAffix, _checkPath, items]);
149
155
 
150
156
  return { tabKey, setTabKey, openKeys, setOpenkeys };
151
157
  };