@sparkelf/dsh-patch-composer-popover-boundaries 0.1.0-rc.26
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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +9 -0
- package/README.zh.md +9 -0
- package/package.json +39 -0
- package/patches/composer-popover-boundaries.patch +334 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write patches/npm/composer-popover-boundaries/README.md
|
|
5
|
+
README.md: 88458c65db1a558904adef4457c2c06706f37c9e
|
|
6
|
+
README.zh.md: be92bc29d54e7248eaf46550cbbf6573f4da000e
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @sparkelf/dsh-patch-composer-popover-boundaries
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
This data-only package keeps the official composer Permission and Model portal menus inside the AppFrame center column. It adds a private center-owner marker, lets the shared Menu intersect viewport placement with an owner boundary, and applies the same calculation to the specialized Model card. Both menus retain 12px horizontal clearance when Sidebar or right-panel widths leave a narrow center.
|
|
6
|
+
|
|
7
|
+
The target is exact official source revision `fb2c4b9e698e30edb738bca4cf0618587db7d203`. The payload contains only the focused production changes, owning package contracts, and existing component and Web user-path coverage; it does not restore the retired Plus mobile layout, Sidebar, Settings, Jobs, Schedule, or responsive-column changes.
|
|
8
|
+
|
|
9
|
+
Retire this package when an official DSH release contains the center-boundary behavior, including the model card's outer-width bound.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @sparkelf/dsh-patch-composer-popover-boundaries
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
该data-only package让official composer的Permission与Model portal菜单保持在AppFrame center column内。它增加私有center-owner标记,使shared Menu把viewport定位与owner boundary取交集,并把相同计算应用到专用Model卡片。Sidebar或右侧面板把center压窄时,两个菜单仍保留12px横向间距。
|
|
6
|
+
|
|
7
|
+
target是exact official source revision `fb2c4b9e698e30edb738bca4cf0618587db7d203`。payload只包含聚焦的生产修改、所属package合同及既有component与Web用户路径覆盖;不会恢复已retire的Plus mobile layout、Sidebar、Settings、Jobs、Schedule或responsive-column改动。
|
|
8
|
+
|
|
9
|
+
当official DSH release包含center-boundary behavior(含model card的outer-width bound)后,retire本package。
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Keep composer permission and model popovers inside the DSH center column",
|
|
3
|
+
"dshPatch": {
|
|
4
|
+
"formatVersion": 1,
|
|
5
|
+
"variants": [
|
|
6
|
+
{
|
|
7
|
+
"dsh": ">=0.1.5-rc.2",
|
|
8
|
+
"file": "./patches/composer-popover-boundaries.patch",
|
|
9
|
+
"id": "composer-popover-center-boundaries",
|
|
10
|
+
"target": {
|
|
11
|
+
"baseRevision": "fb2c4b9e698e30edb738bca4cf0618587db7d203",
|
|
12
|
+
"kind": "dsh-source",
|
|
13
|
+
"paths": [
|
|
14
|
+
"apps/web/tests/",
|
|
15
|
+
"packages/client/ui-layout/",
|
|
16
|
+
"packages/client/ui-primitives/",
|
|
17
|
+
"packages/client/ui-conversation/",
|
|
18
|
+
"packages/client/ui-model-selection/"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"patches/*.patch"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"name": "@sparkelf/dsh-patch-composer-popover-boundaries",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"directory": "patches/npm/composer-popover-boundaries",
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/SparkElf/deepseek-harness-plus.git"
|
|
36
|
+
},
|
|
37
|
+
"type": "module",
|
|
38
|
+
"version": "0.1.0-rc.26"
|
|
39
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
|
|
2
|
+
index cd64d98110..f513b1114a 100644
|
|
3
|
+
--- a/apps/web/tests/access-confirmation.e2e.ts
|
|
4
|
+
+++ b/apps/web/tests/access-confirmation.e2e.ts
|
|
5
|
+
@@ -53,6 +53,12 @@ describe('web e2e: Full access confirmation', () => {
|
|
6
|
+
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:工作区内修改')
|
|
7
|
+
|
|
8
|
+
await access.click()
|
|
9
|
+
+ const permissionMenu = await page.getByRole('menu').boundingBox()
|
|
10
|
+
+ const permissionCenter = await page.locator('[data-dsh-center-col]').boundingBox()
|
|
11
|
+
+ expect(permissionMenu).not.toBeNull()
|
|
12
|
+
+ expect(permissionCenter).not.toBeNull()
|
|
13
|
+
+ expect(permissionMenu!.x).toBeGreaterThanOrEqual(permissionCenter!.x + 11)
|
|
14
|
+
+ expect(permissionMenu!.x + permissionMenu!.width).toBeLessThanOrEqual(permissionCenter!.x + permissionCenter!.width - 11)
|
|
15
|
+
await page.getByRole('menuitem', { name: '完全权限' }).click()
|
|
16
|
+
const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
|
|
17
|
+
await dialog.waitFor({ timeout: 10_000 })
|
|
18
|
+
diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts
|
|
19
|
+
index 304bdb97f6..0e820ee757 100644
|
|
20
|
+
--- a/apps/web/tests/default-model.e2e.ts
|
|
21
|
+
+++ b/apps/web/tests/default-model.e2e.ts
|
|
22
|
+
@@ -105,6 +105,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
|
23
|
+
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
|
24
|
+
await trigger.waitFor({ timeout: 15_000 })
|
|
25
|
+
await trigger.click()
|
|
26
|
+
+ const modelMenu = await page.getByRole('menu').boundingBox()
|
|
27
|
+
+ const modelCenter = await page.locator('[data-dsh-center-col]').boundingBox()
|
|
28
|
+
+ expect(modelMenu).not.toBeNull()
|
|
29
|
+
+ expect(modelCenter).not.toBeNull()
|
|
30
|
+
+ expect(modelMenu!.x).toBeGreaterThanOrEqual(modelCenter!.x + 11)
|
|
31
|
+
+ expect(modelMenu!.x + modelMenu!.width).toBeLessThanOrEqual(modelCenter!.x + modelCenter!.width - 11)
|
|
32
|
+
await page.getByRole('menuitem', { name: /模型/ }).click()
|
|
33
|
+
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
|
34
|
+
|
|
35
|
+
diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
|
|
36
|
+
index b54049efef..6eed9180c0 100644
|
|
37
|
+
--- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
|
|
38
|
+
+++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
|
|
39
|
+
@@ -1,4 +1,4 @@
|
|
40
|
+
-import { useEffect, useState } from 'react'
|
|
41
|
+
+import { useEffect, useRef, useState } from 'react'
|
|
42
|
+
import type { ReactNode } from 'react'
|
|
43
|
+
import clsx from 'clsx'
|
|
44
|
+
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission-presets/client'
|
|
45
|
+
@@ -88,6 +88,8 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
|
|
46
|
+
const [open, setOpen] = useState(false)
|
|
47
|
+
const [confirmation, setConfirmation] = useState<string | null>(null)
|
|
48
|
+
const [acknowledged, setAcknowledged] = useState(false)
|
|
49
|
+
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
50
|
+
+ const boundaryRef = useRef<HTMLElement | null>(null)
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!locked && value !== undefined) return
|
|
54
|
+
@@ -155,8 +157,14 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
|
|
55
|
+
onSelect={choose}
|
|
56
|
+
onClose={() => { setOpen(false) }}
|
|
57
|
+
side="top"
|
|
58
|
+
+ portal
|
|
59
|
+
+ boundaryRef={boundaryRef}
|
|
60
|
+
anchor={
|
|
61
|
+
<button
|
|
62
|
+
+ ref={(node) => {
|
|
63
|
+
+ triggerRef.current = node
|
|
64
|
+
+ boundaryRef.current = node?.closest<HTMLElement>('[data-dsh-center-col]') ?? null
|
|
65
|
+
+ }}
|
|
66
|
+
type="button"
|
|
67
|
+
className={css.trigger}
|
|
68
|
+
aria-label={t('input.accessMode', { name: currentLabel })}
|
|
69
|
+
diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
|
|
70
|
+
index 4fe19fb9ec..976a4dad8c 100644
|
|
71
|
+
--- a/packages/client/ui-layout/README.md
|
|
72
|
+
+++ b/packages/client/ui-layout/README.md
|
|
73
|
+
@@ -43,7 +43,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
|
|
74
|
+
|
|
75
|
+
`selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts.
|
|
76
|
+
|
|
77
|
+
-One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
|
|
78
|
+
+One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The center column marks its DOM owner so portaled composer menus can stay inside its horizontal bounds. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
|
|
79
|
+
|
|
80
|
+
</details>
|
|
81
|
+
|
|
82
|
+
diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md
|
|
83
|
+
index 7a61e7808f..4c80da0778 100644
|
|
84
|
+
--- a/packages/client/ui-layout/README.zh.md
|
|
85
|
+
+++ b/packages/client/ui-layout/README.zh.md
|
|
86
|
+
@@ -43,7 +43,7 @@ kind: "package-reference"
|
|
87
|
+
|
|
88
|
+
`selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。
|
|
89
|
+
|
|
90
|
+
-一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
|
|
91
|
+
+一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。center column标记其DOM owner,使portal composer菜单保持在其横向边界内。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
|
|
92
|
+
|
|
93
|
+
</details>
|
|
94
|
+
|
|
95
|
+
diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
96
|
+
index 05bb4b9494..c156255193 100644
|
|
97
|
+
--- a/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
98
|
+
+++ b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
99
|
+
@@ -33,7 +33,7 @@ export type AppFrameProps =
|
|
100
|
+
|
|
101
|
+
/** Center column grid item (session-body building block). */
|
|
102
|
+
function CenterColumn(props: { children?: ReactNode }) {
|
|
103
|
+
- return <div className={css.centerCol}>{props.children}</div>
|
|
104
|
+
+ return <div className={css.centerCol} data-dsh-center-col>{props.children}</div>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Subscribe to the main key without subscribing the column frame to each panel id. */
|
|
108
|
+
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
109
|
+
index b2cb715287..968d000bc5 100644
|
|
110
|
+
--- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
111
|
+
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
112
|
+
@@ -104,7 +104,9 @@
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-direction: column;
|
|
115
|
+
/* Let the visible pane's rows set the card width, but keep pathological ids
|
|
116
|
+
- and narrow screens inside a bounded card where ellipsis remains. */
|
|
117
|
+
+ and narrow screens inside a bounded card where ellipsis remains. The card's
|
|
118
|
+
+ own pad is part of that bound, as it is on the Menu primitive's surface. */
|
|
119
|
+
+ box-sizing: border-box;
|
|
120
|
+
width: max-content;
|
|
121
|
+
min-width: min(240px, calc(100vw - 32px));
|
|
122
|
+
max-width: min(420px, calc(100vw - 32px));
|
|
123
|
+
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
124
|
+
index e9fc0195ef..637ecb5601 100644
|
|
125
|
+
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
126
|
+
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
127
|
+
@@ -137,13 +137,22 @@ export function ModelSelect(
|
|
128
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
129
|
+
if (rect === undefined) return
|
|
130
|
+
const MARGIN = 12
|
|
131
|
+
+ const boundary = triggerRef.current?.closest<HTMLElement>('[data-dsh-center-col]')?.getBoundingClientRect()
|
|
132
|
+
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
133
|
+
+ const maxX = Math.min(window.innerWidth - MARGIN, (boundary?.right ?? window.innerWidth) - MARGIN)
|
|
134
|
+
+ const availableWidth = Math.max(0, maxX - minX)
|
|
135
|
+
const lw = menuRef.current?.offsetWidth ?? 0
|
|
136
|
+
+ const placedWidth = boundary === undefined ? lw : Math.min(lw, availableWidth)
|
|
137
|
+
const lh = menuRef.current?.offsetHeight ?? 0
|
|
138
|
+
- let x = rect.right - lw
|
|
139
|
+
+ let x = rect.right - placedWidth
|
|
140
|
+
let y = rect.top - 8 - lh
|
|
141
|
+
- if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
|
|
142
|
+
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
143
|
+
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
|
|
144
|
+
- setMenuPos({ left: x, top: y })
|
|
145
|
+
+ setMenuPos({
|
|
146
|
+
+ left: x,
|
|
147
|
+
+ top: y,
|
|
148
|
+
+ ...boundary === undefined ? {} : { minWidth: Math.min(264, availableWidth), maxWidth: availableWidth },
|
|
149
|
+
+ })
|
|
150
|
+
}
|
|
151
|
+
// First run measures the hidden pre-render (same commit as `open`), so
|
|
152
|
+
// the card lands placed before anything paints.
|
|
153
|
+
diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
154
|
+
index 3b8c92d8bf..53b1f03c39 100644
|
|
155
|
+
--- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
156
|
+
+++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
157
|
+
@@ -206,23 +206,28 @@ describe('ModelSelect reasoning effort', () => {
|
|
158
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
|
|
159
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
|
|
160
|
+
try {
|
|
161
|
+
- const { container } = render(<ModelSelect
|
|
162
|
+
+ const { container } = render(<div data-dsh-center-col><ModelSelect
|
|
163
|
+
locked={false}
|
|
164
|
+
available
|
|
165
|
+
directory={createSnapshotStore(state())}
|
|
166
|
+
load={vi.fn()}
|
|
167
|
+
select={vi.fn().mockResolvedValue(true)}
|
|
168
|
+
t={t}
|
|
169
|
+
- />)
|
|
170
|
+
+ /></div>)
|
|
171
|
+
+ Object.defineProperty(container.firstElementChild, 'getBoundingClientRect', {
|
|
172
|
+
+ configurable: true,
|
|
173
|
+
+ value: () => ({ left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) }),
|
|
174
|
+
+ })
|
|
175
|
+
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
|
176
|
+
fireEvent.click(trigger)
|
|
177
|
+
const menu = screen.getByRole('menu')
|
|
178
|
+
// Outside the composer subtree — column overflow clips cannot crop it.
|
|
179
|
+
expect(container.contains(menu)).toBe(false)
|
|
180
|
+
expect(menu.parentElement).toBe(document.body)
|
|
181
|
+
- // jsdom anchor rects are all zero, so the measured 200x300 card clamps
|
|
182
|
+
- // to the 12px viewport margin on both axes.
|
|
183
|
+
- expect(menu.style.left).toBe('12px')
|
|
184
|
+
+ // The owner boundary is narrower than the viewport, so the card keeps
|
|
185
|
+
+ // the 12px center-column margin rather than spilling over the sidebar.
|
|
186
|
+
+ expect(menu.style.left).toBe('292px')
|
|
187
|
+
+ expect(menu.style.maxWidth).toBe('216px')
|
|
188
|
+
expect(menu.style.top).toBe('12px')
|
|
189
|
+
// Interactions inside the trigger subtree or the portaled card stay open.
|
|
190
|
+
fireEvent.mouseDown(menu)
|
|
191
|
+
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
|
|
192
|
+
index 1919e923c6..fe35b2a1f4 100644
|
|
193
|
+
--- a/packages/client/ui-primitives/README.md
|
|
194
|
+
+++ b/packages/client/ui-primitives/README.md
|
|
195
|
+
@@ -39,7 +39,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
|
196
|
+
| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
|
|
197
|
+
| `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
|
|
198
|
+
| `Input` | Single-line text entry for search boxes and inline forms. |
|
|
199
|
+
-| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. |
|
|
200
|
+
+| `Menu` | Dropdown of items, separators, and group labels, with nested submenus; portaled menus may intersect viewport clamping with an owner boundary. |
|
|
201
|
+
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
|
202
|
+
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
|
|
203
|
+
| `StateDot` | Status mark: `done`, `warning`, `ongoing`, `error`, or `idle`. `aria-hidden`, so the render site owns the name. |
|
|
204
|
+
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
|
|
205
|
+
index e0c90fd14a..31011edb72 100644
|
|
206
|
+
--- a/packages/client/ui-primitives/README.zh.md
|
|
207
|
+
+++ b/packages/client/ui-primitives/README.zh.md
|
|
208
|
+
@@ -39,7 +39,7 @@ kind: "package-library"
|
|
209
|
+
| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
|
|
210
|
+
| `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
|
211
|
+
| `Input` | 单行文本输入,用于搜索框与行内表单。 |
|
|
212
|
+
-| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。 |
|
|
213
|
+
+| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;portal菜单可将视口clamp与owner boundary取交集。 |
|
|
214
|
+
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
|
215
|
+
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
|
216
|
+
| `StateDot` | 状态标记:`done`、`warning`、`ongoing`、`error` 或 `idle`。它是 `aria-hidden` 的,名称由渲染点提供。 |
|
|
217
|
+
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
|
|
218
|
+
index db8b5588fe..cff56df8dc 100644
|
|
219
|
+
--- a/packages/client/ui-primitives/src/Menu.tsx
|
|
220
|
+
+++ b/packages/client/ui-primitives/src/Menu.tsx
|
|
221
|
+
@@ -1,5 +1,5 @@
|
|
222
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
223
|
+
-import type { CSSProperties, ReactNode } from 'react'
|
|
224
|
+
+import type { CSSProperties, ReactNode, RefObject } from 'react'
|
|
225
|
+
import { createPortal } from 'react-dom'
|
|
226
|
+
import clsx from 'clsx'
|
|
227
|
+
import { IconCheckOutline16 } from './icons/index.tsx'
|
|
228
|
+
@@ -70,6 +70,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
229
|
+
* gap and a brief overshoot survivable; coming back cancels the close.
|
|
230
|
+
* @param props.dense - reduce vertical row spacing without changing the standard typography or card width.
|
|
231
|
+
* @param props.compact - use reduced menu typography and spacing.
|
|
232
|
+
+ * @param props.boundaryRef - portal mode only: limit horizontal placement to this owner as well as the viewport.
|
|
233
|
+
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
|
234
|
+
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
|
235
|
+
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
|
|
236
|
+
@@ -84,7 +85,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
237
|
+
* crowds the cell).
|
|
238
|
+
* @returns anchor wrapper with the conditional list.
|
|
239
|
+
*/
|
|
240
|
+
-export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: {
|
|
241
|
+
+export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, boundaryRef, footer, className }: {
|
|
242
|
+
open: boolean
|
|
243
|
+
autoFocus?: boolean
|
|
244
|
+
anchor: ReactNode
|
|
245
|
+
@@ -102,6 +103,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
|
|
246
|
+
compact?: boolean
|
|
247
|
+
selection?: 'check' | 'fill'
|
|
248
|
+
getAnchorRect?: () => DOMRect | null
|
|
249
|
+
+ boundaryRef?: RefObject<HTMLElement | null>
|
|
250
|
+
className?: string | undefined
|
|
251
|
+
}) {
|
|
252
|
+
const rootRef = useRef<HTMLSpanElement>(null)
|
|
253
|
+
@@ -129,8 +131,13 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
|
|
254
|
+
const MARGIN = 12
|
|
255
|
+
const vw = window.innerWidth
|
|
256
|
+
const vh = window.innerHeight
|
|
257
|
+
+ const boundary = boundaryRef?.current?.getBoundingClientRect()
|
|
258
|
+
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
259
|
+
+ const maxX = Math.min(vw - MARGIN, (boundary?.right ?? vw) - MARGIN)
|
|
260
|
+
+ const availableWidth = Math.max(0, maxX - minX)
|
|
261
|
+
const listEl = listRef.current
|
|
262
|
+
const lw = listEl?.offsetWidth ?? 0
|
|
263
|
+
+ const placedWidth = boundary === undefined ? lw : Math.min(lw, availableWidth)
|
|
264
|
+
const lh = listEl?.offsetHeight ?? 0
|
|
265
|
+
|
|
266
|
+
let x: number
|
|
267
|
+
@@ -142,14 +149,18 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
|
|
268
|
+
x = r.left
|
|
269
|
+
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
|
270
|
+
} else {
|
|
271
|
+
- x = r.right - lw
|
|
272
|
+
+ x = r.right - placedWidth
|
|
273
|
+
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
- if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
|
|
277
|
+
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
278
|
+
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
|
|
279
|
+
|
|
280
|
+
- setFixedPos({ left: x, top: y })
|
|
281
|
+
+ setFixedPos({
|
|
282
|
+
+ left: x,
|
|
283
|
+
+ top: y,
|
|
284
|
+
+ ...boundary === undefined ? {} : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth },
|
|
285
|
+
+ })
|
|
286
|
+
}
|
|
287
|
+
// First run measures the hidden pre-render (same commit as `open`), so
|
|
288
|
+
// end/top alignment and clamping use real dimensions before anything
|
|
289
|
+
@@ -161,7 +172,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
|
|
290
|
+
window.removeEventListener('scroll', place, true)
|
|
291
|
+
window.removeEventListener('resize', place)
|
|
292
|
+
}
|
|
293
|
+
- }, [open, portal, align, side, getAnchorRect])
|
|
294
|
+
+ }, [open, portal, align, side, getAnchorRect, boundaryRef])
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (open && autoFocus) listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus()
|
|
298
|
+
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
299
|
+
index 70649d0874..c768b406ff 100644
|
|
300
|
+
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
301
|
+
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
302
|
+
@@ -337,6 +337,32 @@ describe('Menu', () => {
|
|
303
|
+
expect(menu.style.top).toBe('132px')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
+ it('portal mode intersects viewport clamping with an owner boundary', () => {
|
|
307
|
+
+ const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
|
|
308
|
+
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 300 })
|
|
309
|
+
+ const anchor = { left: 480, right: 500, top: 100, bottom: 128, width: 20, height: 28, x: 480, y: 100, toJSON: () => ({}) } as DOMRect
|
|
310
|
+
+ const boundary = { left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) } as DOMRect
|
|
311
|
+
+ try {
|
|
312
|
+
+ render(<Menu
|
|
313
|
+
+ portal
|
|
314
|
+
+ open
|
|
315
|
+
+ align="end"
|
|
316
|
+
+ getAnchorRect={() => anchor}
|
|
317
|
+
+ boundaryRef={{ current: { getBoundingClientRect: () => boundary } as HTMLElement }}
|
|
318
|
+
+ anchor={null}
|
|
319
|
+
+ items={items}
|
|
320
|
+
+ onSelect={() => {}}
|
|
321
|
+
+ onClose={() => {}}
|
|
322
|
+
+ />)
|
|
323
|
+
+ const menu = screen.getByRole('menu')
|
|
324
|
+
+ expect(menu.style.left).toBe('292px')
|
|
325
|
+
+ expect(menu.style.minWidth).toBe('216px')
|
|
326
|
+
+ expect(menu.style.maxWidth).toBe('216px')
|
|
327
|
+
+ } finally {
|
|
328
|
+
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
|
|
329
|
+
+ }
|
|
330
|
+
+ })
|
|
331
|
+
+
|
|
332
|
+
it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => {
|
|
333
|
+
render(
|
|
334
|
+
<Menu
|