@sparkelf/dsh-patch-composer-popover-boundaries 0.2.0-rc.8 → 0.2.1-alpha.1
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/package.json
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
"formatVersion": 1,
|
|
5
5
|
"variants": [
|
|
6
6
|
{
|
|
7
|
-
"dsh": ">=0.
|
|
7
|
+
"dsh": ">=0.2.0-rc.2",
|
|
8
8
|
"file": "./patches/composer-popover-boundaries.patch",
|
|
9
9
|
"id": "composer-popover-center-boundaries",
|
|
10
10
|
"target": {
|
|
11
|
-
"baseRevision": "
|
|
11
|
+
"baseRevision": "5badb15009ae1756c3afe0ae0cef1faafc290ccc",
|
|
12
12
|
"kind": "dsh-source",
|
|
13
13
|
"paths": [
|
|
14
14
|
"apps/web/tests/",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"url": "git+https://github.com/SparkElf/deepseek-harness-plus.git"
|
|
36
36
|
},
|
|
37
37
|
"type": "module",
|
|
38
|
-
"version": "0.2.
|
|
38
|
+
"version": "0.2.1-alpha.1"
|
|
39
39
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
|
|
2
|
-
index
|
|
2
|
+
index a47cad6..6c05516 100644
|
|
3
3
|
--- a/apps/web/tests/access-confirmation.e2e.ts
|
|
4
4
|
+++ b/apps/web/tests/access-confirmation.e2e.ts
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -151,6 +151,12 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
|
|
6
6
|
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:工作区内修改')
|
|
7
7
|
|
|
8
8
|
await access.click()
|
|
@@ -16,37 +16,38 @@ index 2b2ba0e..bd61e45 100644
|
|
|
16
16
|
const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
|
|
17
17
|
await dialog.waitFor({ timeout: 10_000 })
|
|
18
18
|
diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts
|
|
19
|
-
index
|
|
19
|
+
index d87bf4c..901bf5f 100644
|
|
20
20
|
--- a/apps/web/tests/default-model.e2e.ts
|
|
21
21
|
+++ b/apps/web/tests/default-model.e2e.ts
|
|
22
|
-
@@ -
|
|
23
|
-
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
|
22
|
+
@@ -323,6 +323,13 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
|
24
23
|
await trigger.waitFor({ timeout: 15_000 })
|
|
24
|
+
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
|
|
25
25
|
await trigger.click()
|
|
26
|
-
+
|
|
27
|
-
+ const
|
|
28
|
-
+
|
|
29
|
-
+ expect(
|
|
30
|
-
+ expect(
|
|
31
|
-
+ expect(
|
|
32
|
-
|
|
33
|
-
await page.getByRole('
|
|
34
|
-
|
|
26
|
+
+ // The composer popover must stay inside the centre column rather than touching its edges.
|
|
27
|
+
+ const boundaryMenu = await page.getByRole('menu').boundingBox()
|
|
28
|
+
+ const boundaryCenter = await page.locator('[data-dsh-center-col]').boundingBox()
|
|
29
|
+
+ expect(boundaryMenu).not.toBeNull()
|
|
30
|
+
+ expect(boundaryCenter).not.toBeNull()
|
|
31
|
+
+ expect(boundaryMenu!.x).toBeGreaterThanOrEqual(boundaryCenter!.x + 11)
|
|
32
|
+
+ expect(boundaryMenu!.x + boundaryMenu!.width).toBeLessThanOrEqual(boundaryCenter!.x + boundaryCenter!.width - 11)
|
|
33
|
+
const modelMenuBounds = await page.getByRole('menu', { name: '模型与推理等级', exact: true }).boundingBox()
|
|
34
|
+
const composerBounds = await page.locator('[data-composer-card]').first().boundingBox()
|
|
35
|
+
expect(modelMenuBounds!.width).toBeLessThan(composerBounds!.width)
|
|
35
36
|
diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
|
|
36
|
-
index
|
|
37
|
+
index acc75b5..0a47e29 100644
|
|
37
38
|
--- a/packages/client/ui-layout/README.md
|
|
38
39
|
+++ b/packages/client/ui-layout/README.md
|
|
39
|
-
@@ -
|
|
40
|
+
@@ -56,7 +56,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
|
|
40
41
|
|
|
41
42
|
`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.
|
|
42
43
|
|
|
43
|
-
-One registration declares
|
|
44
|
-
+One registration declares
|
|
44
|
+
-One registration declares the frame 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. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; 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.
|
|
45
|
+
+One registration declares the frame 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. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; 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.
|
|
45
46
|
|
|
46
47
|
</details>
|
|
47
48
|
|
|
48
49
|
diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
49
|
-
index
|
|
50
|
+
index 5614947..e3fc26d 100644
|
|
50
51
|
--- a/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
51
52
|
+++ b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
52
53
|
@@ -33,7 +33,7 @@ export type AppFrameProps =
|
|
@@ -59,10 +60,10 @@ index 323318f..86a33a3 100644
|
|
|
59
60
|
|
|
60
61
|
/** Subscribe to the main key without subscribing the column frame to each panel id. */
|
|
61
62
|
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
62
|
-
index
|
|
63
|
+
index f4f42cb..3b01c1a 100644
|
|
63
64
|
--- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
64
65
|
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
65
|
-
@@ -
|
|
66
|
+
@@ -94,7 +94,9 @@
|
|
66
67
|
display: flex;
|
|
67
68
|
flex-direction: column;
|
|
68
69
|
/* Let the visible pane's rows set the card width, but keep pathological ids
|
|
@@ -74,13 +75,15 @@ index b2cb715..968d000 100644
|
|
|
74
75
|
min-width: min(240px, calc(100vw - 32px));
|
|
75
76
|
max-width: min(420px, calc(100vw - 32px));
|
|
76
77
|
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
77
|
-
index
|
|
78
|
+
index fbeda5d..1857d75 100644
|
|
78
79
|
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
79
80
|
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
80
|
-
@@ -
|
|
81
|
+
@@ -217,13 +217,25 @@ export function ModelSelect(
|
|
81
82
|
const rect = triggerRef.current?.getBoundingClientRect()
|
|
82
83
|
if (rect === undefined) return
|
|
83
84
|
const MARGIN = 12
|
|
85
|
+
+ // The composer's center column narrows both clamp edges when this trigger
|
|
86
|
+
+ // sits in one; without it the viewport alone decides, as before.
|
|
84
87
|
+ const boundary = triggerRef.current?.closest<HTMLElement>('[data-dsh-center-col]')?.getBoundingClientRect()
|
|
85
88
|
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
86
89
|
+ const maxX = Math.min(window.innerWidth - MARGIN, (boundary?.right ?? window.innerWidth) - MARGIN)
|
|
@@ -95,19 +98,20 @@ index b5a55f5..7252e44 100644
|
|
|
95
98
|
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
96
99
|
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
|
|
97
100
|
- setMenuPos({ left: x, top: y })
|
|
98
|
-
+
|
|
99
|
-
+
|
|
100
|
-
+
|
|
101
|
-
+
|
|
102
|
-
+
|
|
101
|
+
+ // A bounded card takes the owner's width: the same 12px inset on both
|
|
102
|
+
+ // sides, never wider than the room between them.
|
|
103
|
+
+ const bounds = boundary === undefined
|
|
104
|
+
+ ? {}
|
|
105
|
+
+ : { minWidth: Math.min(264, availableWidth), maxWidth: availableWidth }
|
|
106
|
+
+ setMenuPos({ left: x, top: y, ...bounds })
|
|
103
107
|
}
|
|
104
108
|
// First run measures the hidden pre-render (same commit as `open`), so
|
|
105
109
|
// the card lands placed before anything paints.
|
|
106
110
|
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
|
|
107
|
-
index
|
|
111
|
+
index 470c852..40998a1 100644
|
|
108
112
|
--- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
109
113
|
+++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
110
|
-
@@ -
|
|
114
|
+
@@ -315,23 +315,30 @@ describe('ModelSelect reasoning effort', () => {
|
|
111
115
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
|
|
112
116
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
|
|
113
117
|
try {
|
|
@@ -121,6 +125,7 @@ index 4a5ee43..a70e88d 100644
|
|
|
121
125
|
t={t}
|
|
122
126
|
- />)
|
|
123
127
|
+ /></div>)
|
|
128
|
+
+ // The composer card's own rect is a zero rect in jsdom; the column's is not.
|
|
124
129
|
+ Object.defineProperty(container.firstElementChild, 'getBoundingClientRect', {
|
|
125
130
|
+ configurable: true,
|
|
126
131
|
+ value: () => ({ left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) }),
|
|
@@ -134,15 +139,16 @@ index 4a5ee43..a70e88d 100644
|
|
|
134
139
|
- // jsdom anchor rects are all zero, so the measured 200x300 card clamps
|
|
135
140
|
- // to the 12px viewport margin on both axes.
|
|
136
141
|
- expect(menu.style.left).toBe('12px')
|
|
137
|
-
+ // The owner
|
|
138
|
-
+ // the 12px
|
|
142
|
+
+ // The owner column is narrower than the viewport, so the measured
|
|
143
|
+
+ // 200x300 card keeps the 12px column inset instead of spilling over the
|
|
144
|
+
+ // sidebar; vertically only the viewport clamps.
|
|
139
145
|
+ expect(menu.style.left).toBe('292px')
|
|
140
146
|
+ expect(menu.style.maxWidth).toBe('216px')
|
|
141
147
|
expect(menu.style.top).toBe('12px')
|
|
142
148
|
// Interactions inside the trigger subtree or the portaled card stay open.
|
|
143
|
-
fireEvent.mouseDown(menu)
|
|
149
|
+
expect(fireEvent.mouseDown(menu)).toBe(true)
|
|
144
150
|
diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
145
|
-
index
|
|
151
|
+
index f34e2b1..c5db564 100644
|
|
146
152
|
--- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
147
153
|
+++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
148
154
|
@@ -1,4 +1,4 @@
|
|
@@ -151,16 +157,16 @@ index 1736fd8..efd2496 100644
|
|
|
151
157
|
import type { ReactNode } from 'react'
|
|
152
158
|
import clsx from 'clsx'
|
|
153
159
|
import {
|
|
154
|
-
@@ -
|
|
160
|
+
@@ -80,6 +80,8 @@ export function PermissionSelect({
|
|
155
161
|
const catalog = usePermissionCatalog(state => state.value)
|
|
156
162
|
const [pick, setPick] = useState<string | null>(null)
|
|
157
163
|
const [open, setOpen] = useState(false)
|
|
158
|
-
+
|
|
164
|
+
+ /** The center column this trigger sits in; the portaled menu clamps to it. */
|
|
159
165
|
+ const boundaryRef = useRef<HTMLElement | null>(null)
|
|
160
166
|
const [confirmation, setConfirmation] = useState<string | null>(null)
|
|
161
167
|
const [acknowledged, setAcknowledged] = useState(false)
|
|
162
168
|
|
|
163
|
-
@@ -
|
|
169
|
+
@@ -172,8 +174,12 @@ export function PermissionSelect({
|
|
164
170
|
onClose={() => { setOpen(false) }}
|
|
165
171
|
side="top"
|
|
166
172
|
portal
|
|
@@ -168,78 +174,82 @@ index 1736fd8..efd2496 100644
|
|
|
168
174
|
anchor={
|
|
169
175
|
<button
|
|
170
176
|
+ ref={(node) => {
|
|
171
|
-
+ triggerRef.current = node
|
|
172
177
|
+ boundaryRef.current = node?.closest<HTMLElement>('[data-dsh-center-col]') ?? null
|
|
173
178
|
+ }}
|
|
174
179
|
type="button"
|
|
175
180
|
className={css.trigger}
|
|
176
181
|
aria-label={t('mode', { name: currentAccessibleLabel })}
|
|
177
182
|
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
|
|
178
|
-
index
|
|
183
|
+
index 9ce34c7..15b8360 100644
|
|
179
184
|
--- a/packages/client/ui-primitives/README.md
|
|
180
185
|
+++ b/packages/client/ui-primitives/README.md
|
|
181
|
-
@@ -
|
|
182
|
-
| `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
|
|
186
|
+
@@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
|
183
187
|
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
|
|
184
|
-
| `Input` | Single-line text entry for search boxes and inline forms. |
|
|
185
|
-
|
|
186
|
-
|
|
188
|
+
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
|
|
189
|
+
| `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
|
|
190
|
+
-| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
|
|
191
|
+
+| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A portaled list may intersect viewport clamping with an owner boundary. |
|
|
192
|
+
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
|
|
187
193
|
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
|
188
|
-
| `
|
|
189
|
-
| `StateDot` | Status mark: `done`, `warning`, `ongoing`, `error`, or `idle`. `aria-hidden`, so the render site owns the name. |
|
|
194
|
+
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
|
|
190
195
|
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
|
|
191
|
-
index
|
|
196
|
+
index 92a8de2..a9dcf91 100644
|
|
192
197
|
--- a/packages/client/ui-primitives/README.zh.md
|
|
193
198
|
+++ b/packages/client/ui-primitives/README.zh.md
|
|
194
|
-
@@ -
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
+| `Menu` |
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
@@ -25,7 +25,7 @@ kind: "package-library"
|
|
200
|
+
<a id="use-this-package"></a>
|
|
201
|
+
## 使用本包
|
|
202
|
+
|
|
203
|
+
-`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
|
204
|
+
+| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。portal 模式的列表可把视口钳制与 owner boundary 取交集。 |
|
|
205
|
+
|
|
206
|
+
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
|
|
207
|
+
|
|
203
208
|
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
|
|
204
|
-
index
|
|
209
|
+
index fd5b655..cd0a7ea 100644
|
|
205
210
|
--- a/packages/client/ui-primitives/src/Menu.tsx
|
|
206
211
|
+++ b/packages/client/ui-primitives/src/Menu.tsx
|
|
207
212
|
@@ -1,5 +1,5 @@
|
|
208
213
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
209
|
-
-import type { CSSProperties, ReactNode } from 'react'
|
|
210
|
-
+import type { CSSProperties, ReactNode, RefObject } from 'react'
|
|
214
|
+
-import type { CSSProperties, ReactNode, SyntheticEvent } from 'react'
|
|
215
|
+
+import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react'
|
|
211
216
|
import { createPortal } from 'react-dom'
|
|
212
217
|
import clsx from 'clsx'
|
|
213
|
-
import {
|
|
214
|
-
@@ -
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
+ * @param props.boundaryRef - portal mode only:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
*
|
|
218
|
+
import { IconCheckOutlineRegular } from './icons/index.tsx'
|
|
219
|
+
@@ -149,6 +149,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
220
|
+
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
|
|
221
|
+
* wrapper there races the host's layout effects). Called on open, each animation
|
|
222
|
+
* frame, and scroll/resize; return null to skip placement for that frame.
|
|
223
|
+
+ * @param props.boundaryRef - portal mode only: intersect horizontal placement
|
|
224
|
+
+ * with this owner element's rect as well as the viewport, so a menu owned by a
|
|
225
|
+
+ * column stays inside that column when a sidebar or panel leaves a narrow
|
|
226
|
+
+ * center.
|
|
227
|
+
* @param props.footer - rows pinned below the scrolling items area, separated
|
|
228
|
+
* by a hairline; they stay visible while the items above scroll.
|
|
229
|
+
* @param props.children - component rows rendered after `items` in the same
|
|
230
|
+
@@ -165,7 +169,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
231
|
+
* document.body outside the owner's DOM subtree.
|
|
224
232
|
* @returns anchor wrapper with the conditional list.
|
|
225
233
|
*/
|
|
226
|
-
-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 }: {
|
|
227
|
-
+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 }: {
|
|
234
|
+
-export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: {
|
|
235
|
+
+export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, boundaryRef, footer, className, listClassName }: {
|
|
228
236
|
open: boolean
|
|
229
237
|
autoFocus?: boolean
|
|
230
238
|
anchor: ReactNode
|
|
231
|
-
@@ -
|
|
239
|
+
@@ -184,6 +188,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
232
240
|
compact?: boolean
|
|
233
241
|
selection?: 'check' | 'fill'
|
|
234
242
|
getAnchorRect?: () => DOMRect | null
|
|
235
243
|
+ boundaryRef?: RefObject<HTMLElement | null>
|
|
236
244
|
className?: string | undefined
|
|
245
|
+
listClassName?: string | undefined
|
|
237
246
|
}) {
|
|
238
|
-
|
|
239
|
-
@@ -174,8 +176,13 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
|
|
247
|
+
@@ -255,8 +260,15 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
240
248
|
const MARGIN = 12
|
|
241
249
|
const vw = window.innerWidth
|
|
242
250
|
const vh = window.innerHeight
|
|
251
|
+
+ // The owner boundary narrows both clamp edges; without one the viewport
|
|
252
|
+
+ // alone decides, exactly as before.
|
|
243
253
|
+ const boundary = boundaryRef?.current?.getBoundingClientRect()
|
|
244
254
|
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
245
255
|
+ const maxX = Math.min(vw - MARGIN, (boundary?.right ?? vw) - MARGIN)
|
|
@@ -250,7 +260,7 @@ index bb04912..1e1e1c7 100644
|
|
|
250
260
|
const lh = listEl?.offsetHeight ?? 0
|
|
251
261
|
|
|
252
262
|
let x: number
|
|
253
|
-
@@ -
|
|
263
|
+
@@ -268,14 +280,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
254
264
|
x = r.left
|
|
255
265
|
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
|
256
266
|
} else {
|
|
@@ -261,18 +271,22 @@ index bb04912..1e1e1c7 100644
|
|
|
261
271
|
|
|
262
272
|
- if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
|
|
263
273
|
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
264
|
-
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
|
|
274
|
+
if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN)
|
|
265
275
|
|
|
266
|
-
- setFixedPos({ left: x, top: y })
|
|
267
|
-
+
|
|
268
|
-
+
|
|
269
|
-
+
|
|
270
|
-
+
|
|
271
|
-
+
|
|
276
|
+
- setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y })
|
|
277
|
+
+ // A bounded card takes the owner's width: the same 12px inset on both
|
|
278
|
+
+ // sides, never wider than the room between them.
|
|
279
|
+
+ const bounds = boundary === undefined
|
|
280
|
+
+ ? {}
|
|
281
|
+
+ : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth }
|
|
282
|
+
+ setFixedPos(current => current?.left === x && current.top === y
|
|
283
|
+
+ && current.minWidth === bounds.minWidth && current.maxWidth === bounds.maxWidth
|
|
284
|
+
+ ? current
|
|
285
|
+
+ : { left: x, top: y, ...bounds })
|
|
272
286
|
}
|
|
273
287
|
// First run measures the hidden pre-render (same commit as `open`), so
|
|
274
288
|
// end/top alignment and clamping use real dimensions before anything
|
|
275
|
-
@@ -
|
|
289
|
+
@@ -294,7 +314,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
276
290
|
window.removeEventListener('scroll', place, true)
|
|
277
291
|
window.removeEventListener('resize', place)
|
|
278
292
|
}
|
|
@@ -282,10 +296,10 @@ index bb04912..1e1e1c7 100644
|
|
|
282
296
|
// Opening remembers where the keyboard was, so closing can hand it back to
|
|
283
297
|
// that control — an anchor wrapping several (a split button) cannot be asked
|
|
284
298
|
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
285
|
-
index
|
|
299
|
+
index bc48ea0..767b60a 100644
|
|
286
300
|
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
287
301
|
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
288
|
-
@@ -
|
|
302
|
+
@@ -504,6 +504,33 @@ describe('Menu', () => {
|
|
289
303
|
expect(menu.style.top).toBe('132px')
|
|
290
304
|
})
|
|
291
305
|
|
|
@@ -307,6 +321,7 @@ index a2f6b70..458c825 100644
|
|
|
307
321
|
+ onClose={() => {}}
|
|
308
322
|
+ />)
|
|
309
323
|
+ const menu = screen.getByRole('menu')
|
|
324
|
+
+ // The owner is 240px wide, so the 12px insets leave 216px for a 300px card.
|
|
310
325
|
+ expect(menu.style.left).toBe('292px')
|
|
311
326
|
+ expect(menu.style.minWidth).toBe('216px')
|
|
312
327
|
+ expect(menu.style.maxWidth).toBe('216px')
|