@sparkelf/dsh-patch-composer-popover-boundaries 0.2.0-rc.2 → 0.2.0-rc.21
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.1.
|
|
7
|
+
"dsh": ">=0.1.7-alpha.2",
|
|
8
8
|
"file": "./patches/composer-popover-boundaries.patch",
|
|
9
9
|
"id": "composer-popover-center-boundaries",
|
|
10
10
|
"target": {
|
|
11
|
-
"baseRevision": "
|
|
11
|
+
"baseRevision": "00102833dfaee1da9f48a3a8eae9d34005a75218",
|
|
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.0-rc.
|
|
38
|
+
"version": "0.2.0-rc.21"
|
|
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 b1c57c60e7..9d09f2aefb 100644
|
|
3
3
|
--- a/apps/web/tests/access-confirmation.e2e.ts
|
|
4
4
|
+++ b/apps/web/tests/access-confirmation.e2e.ts
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -150,6 +150,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,7 +16,7 @@ index cd64d98110..f513b1114a 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 474afb1c36..dbbbac33ed 100644
|
|
20
20
|
--- a/apps/web/tests/default-model.e2e.ts
|
|
21
21
|
+++ b/apps/web/tests/default-model.e2e.ts
|
|
22
22
|
@@ -105,6 +105,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
|
@@ -32,54 +32,21 @@ index 304bdb97f6..0e820ee757 100644
|
|
|
32
32
|
await page.getByRole('menuitem', { name: /模型/ }).click()
|
|
33
33
|
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
|
34
34
|
|
|
35
|
-
diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
36
|
-
index 1736fd8..efd2496 100644
|
|
37
|
-
--- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
38
|
-
+++ b/packages/client/ui-permission-presets/src/client/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 {
|
|
45
|
-
@@ -100,6 +100,8 @@ export function PermissionSelect({
|
|
46
|
-
const catalog = usePermissionCatalog(state => state.value)
|
|
47
|
-
const [pick, setPick] = useState<string | null>(null)
|
|
48
|
-
const [open, setOpen] = useState(false)
|
|
49
|
-
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
50
|
-
+ const boundaryRef = useRef<HTMLElement | null>(null)
|
|
51
|
-
const [confirmation, setConfirmation] = useState<string | null>(null)
|
|
52
|
-
const [acknowledged, setAcknowledged] = useState(false)
|
|
53
|
-
|
|
54
|
-
@@ -192,8 +194,13 @@ export function PermissionSelect({
|
|
55
|
-
onClose={() => { setOpen(false) }}
|
|
56
|
-
side="top"
|
|
57
|
-
portal
|
|
58
|
-
+ boundaryRef={boundaryRef}
|
|
59
|
-
anchor={
|
|
60
|
-
<button
|
|
61
|
-
+ ref={(node) => {
|
|
62
|
-
+ triggerRef.current = node
|
|
63
|
-
+ boundaryRef.current = node?.closest<HTMLElement>('[data-dsh-center-col]') ?? null
|
|
64
|
-
+ }}
|
|
65
|
-
type="button"
|
|
66
|
-
className={css.trigger}
|
|
67
|
-
aria-label={t('mode', { name: currentAccessibleLabel })}
|
|
68
35
|
diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
|
|
69
|
-
index
|
|
36
|
+
index b26b182f5e..55391c0bb3 100644
|
|
70
37
|
--- a/packages/client/ui-layout/README.md
|
|
71
38
|
+++ b/packages/client/ui-layout/README.md
|
|
72
|
-
@@ -
|
|
39
|
+
@@ -49,7 +49,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
|
|
73
40
|
|
|
74
41
|
`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.
|
|
75
42
|
|
|
76
|
-
-One registration declares
|
|
77
|
-
+One registration declares
|
|
43
|
+
-One registration declares five 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.
|
|
44
|
+
+One registration declares five 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.
|
|
78
45
|
|
|
79
46
|
</details>
|
|
80
47
|
|
|
81
48
|
diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
82
|
-
index
|
|
49
|
+
index e9ed81aa47..9a3ee7c192 100644
|
|
83
50
|
--- a/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
84
51
|
+++ b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
85
52
|
@@ -33,7 +33,7 @@ export type AppFrameProps =
|
|
@@ -92,10 +59,10 @@ index 05bb4b9494..c156255193 100644
|
|
|
92
59
|
|
|
93
60
|
/** Subscribe to the main key without subscribing the column frame to each panel id. */
|
|
94
61
|
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
95
|
-
index
|
|
62
|
+
index b3a60fb6ec..61a5638145 100644
|
|
96
63
|
--- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
97
64
|
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
98
|
-
@@ -
|
|
65
|
+
@@ -94,7 +94,9 @@
|
|
99
66
|
display: flex;
|
|
100
67
|
flex-direction: column;
|
|
101
68
|
/* Let the visible pane's rows set the card width, but keep pathological ids
|
|
@@ -107,13 +74,15 @@ index b2cb715287..968d000bc5 100644
|
|
|
107
74
|
min-width: min(240px, calc(100vw - 32px));
|
|
108
75
|
max-width: min(420px, calc(100vw - 32px));
|
|
109
76
|
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
110
|
-
index
|
|
77
|
+
index b9c8993563..9797fb4c8b 100644
|
|
111
78
|
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
112
79
|
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
113
|
-
@@ -
|
|
80
|
+
@@ -167,13 +167,25 @@ export function ModelSelect(
|
|
114
81
|
const rect = triggerRef.current?.getBoundingClientRect()
|
|
115
82
|
if (rect === undefined) return
|
|
116
83
|
const MARGIN = 12
|
|
84
|
+
+ // The composer's center column narrows both clamp edges when this trigger
|
|
85
|
+
+ // sits in one; without it the viewport alone decides, as before.
|
|
117
86
|
+ const boundary = triggerRef.current?.closest<HTMLElement>('[data-dsh-center-col]')?.getBoundingClientRect()
|
|
118
87
|
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
119
88
|
+ const maxX = Math.min(window.innerWidth - MARGIN, (boundary?.right ?? window.innerWidth) - MARGIN)
|
|
@@ -128,19 +97,20 @@ index e9fc0195ef..637ecb5601 100644
|
|
|
128
97
|
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
129
98
|
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
|
|
130
99
|
- setMenuPos({ left: x, top: y })
|
|
131
|
-
+
|
|
132
|
-
+
|
|
133
|
-
+
|
|
134
|
-
+
|
|
135
|
-
+
|
|
100
|
+
+ // A bounded card takes the owner's width: the same 12px inset on both
|
|
101
|
+
+ // sides, never wider than the room between them.
|
|
102
|
+
+ const bounds = boundary === undefined
|
|
103
|
+
+ ? {}
|
|
104
|
+
+ : { minWidth: Math.min(264, availableWidth), maxWidth: availableWidth }
|
|
105
|
+
+ setMenuPos({ left: x, top: y, ...bounds })
|
|
136
106
|
}
|
|
137
107
|
// First run measures the hidden pre-render (same commit as `open`), so
|
|
138
108
|
// the card lands placed before anything paints.
|
|
139
109
|
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
|
|
140
|
-
index
|
|
110
|
+
index 4a5ee43b91..7c07d68cb2 100644
|
|
141
111
|
--- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
142
112
|
+++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
143
|
-
@@ -
|
|
113
|
+
@@ -213,23 +213,30 @@ describe('ModelSelect reasoning effort', () => {
|
|
144
114
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
|
|
145
115
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
|
|
146
116
|
try {
|
|
@@ -150,10 +120,11 @@ index 3b8c92d8bf..53b1f03c39 100644
|
|
|
150
120
|
available
|
|
151
121
|
directory={createSnapshotStore(state())}
|
|
152
122
|
load={vi.fn()}
|
|
153
|
-
select={vi.fn().mockResolvedValue(true)}
|
|
123
|
+
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
|
154
124
|
t={t}
|
|
155
125
|
- />)
|
|
156
126
|
+ /></div>)
|
|
127
|
+
+ // The composer card's own rect is a zero rect in jsdom; the column's is not.
|
|
157
128
|
+ Object.defineProperty(container.firstElementChild, 'getBoundingClientRect', {
|
|
158
129
|
+ configurable: true,
|
|
159
130
|
+ value: () => ({ left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) }),
|
|
@@ -167,79 +138,117 @@ index 3b8c92d8bf..53b1f03c39 100644
|
|
|
167
138
|
- // jsdom anchor rects are all zero, so the measured 200x300 card clamps
|
|
168
139
|
- // to the 12px viewport margin on both axes.
|
|
169
140
|
- expect(menu.style.left).toBe('12px')
|
|
170
|
-
+ // The owner
|
|
171
|
-
+ // the 12px
|
|
141
|
+
+ // The owner column is narrower than the viewport, so the measured
|
|
142
|
+
+ // 200x300 card keeps the 12px column inset instead of spilling over the
|
|
143
|
+
+ // sidebar; vertically only the viewport clamps.
|
|
172
144
|
+ expect(menu.style.left).toBe('292px')
|
|
173
145
|
+ expect(menu.style.maxWidth).toBe('216px')
|
|
174
146
|
expect(menu.style.top).toBe('12px')
|
|
175
147
|
// Interactions inside the trigger subtree or the portaled card stay open.
|
|
176
148
|
fireEvent.mouseDown(menu)
|
|
149
|
+
diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
150
|
+
index f34e2b16e9..c5db564e1a 100644
|
|
151
|
+
--- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
152
|
+
+++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
153
|
+
@@ -1,4 +1,4 @@
|
|
154
|
+
-import { useEffect, useState } from 'react'
|
|
155
|
+
+import { useEffect, useRef, useState } from 'react'
|
|
156
|
+
import type { ReactNode } from 'react'
|
|
157
|
+
import clsx from 'clsx'
|
|
158
|
+
import {
|
|
159
|
+
@@ -80,6 +80,8 @@ export function PermissionSelect({
|
|
160
|
+
const catalog = usePermissionCatalog(state => state.value)
|
|
161
|
+
const [pick, setPick] = useState<string | null>(null)
|
|
162
|
+
const [open, setOpen] = useState(false)
|
|
163
|
+
+ /** The center column this trigger sits in; the portaled menu clamps to it. */
|
|
164
|
+
+ const boundaryRef = useRef<HTMLElement | null>(null)
|
|
165
|
+
const [confirmation, setConfirmation] = useState<string | null>(null)
|
|
166
|
+
const [acknowledged, setAcknowledged] = useState(false)
|
|
167
|
+
|
|
168
|
+
@@ -172,8 +174,12 @@ export function PermissionSelect({
|
|
169
|
+
onClose={() => { setOpen(false) }}
|
|
170
|
+
side="top"
|
|
171
|
+
portal
|
|
172
|
+
+ boundaryRef={boundaryRef}
|
|
173
|
+
anchor={
|
|
174
|
+
<button
|
|
175
|
+
+ ref={(node) => {
|
|
176
|
+
+ boundaryRef.current = node?.closest<HTMLElement>('[data-dsh-center-col]') ?? null
|
|
177
|
+
+ }}
|
|
178
|
+
type="button"
|
|
179
|
+
className={css.trigger}
|
|
180
|
+
aria-label={t('mode', { name: currentAccessibleLabel })}
|
|
177
181
|
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
|
|
178
|
-
index
|
|
182
|
+
index 3328454710..2255803273 100644
|
|
179
183
|
--- a/packages/client/ui-primitives/README.md
|
|
180
184
|
+++ b/packages/client/ui-primitives/README.md
|
|
181
|
-
@@ -
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
185
|
+
@@ -41,7 +41,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
|
186
|
+
| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`<id>-<value>`) and the panel it controls (`<id>-<value>-panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. |
|
|
187
|
+
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
|
|
184
188
|
| `Input` | Single-line text entry for search boxes and inline forms. |
|
|
185
|
-
-| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. |
|
|
186
|
-
+| `Menu` | Dropdown of items, separators, and group labels, with nested submenus; portaled
|
|
189
|
+
-| `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. |
|
|
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. A portaled list may intersect viewport clamping with an owner boundary. |
|
|
187
191
|
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
|
192
|
+
| `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. |
|
|
188
193
|
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
|
|
189
|
-
| `StateDot` | Status mark: `done`, `warning`, `ongoing`, `error`, or `idle`. `aria-hidden`, so the render site owns the name. |
|
|
190
194
|
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
|
|
191
|
-
index
|
|
195
|
+
index 598b561e79..dba4808fc2 100644
|
|
192
196
|
--- a/packages/client/ui-primitives/README.zh.md
|
|
193
197
|
+++ b/packages/client/ui-primitives/README.zh.md
|
|
194
|
-
@@ -
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
198
|
+
@@ -41,7 +41,7 @@ kind: "package-library"
|
|
199
|
+
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`<id>-<value>`)及其控制的面板 id(`<id>-<value>-panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 |
|
|
200
|
+
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
|
197
201
|
| `Input` | 单行文本输入,用于搜索框与行内表单。 |
|
|
198
|
-
-| `Menu` |
|
|
199
|
-
+| `Menu` |
|
|
202
|
+
-| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
|
203
|
+
+| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。portal 模式的列表可把视口钳制与 owner boundary 取交集。 |
|
|
200
204
|
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
|
205
|
+
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
|
201
206
|
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
|
202
|
-
| `StateDot` | 状态标记:`done`、`warning`、`ongoing`、`error` 或 `idle`。它是 `aria-hidden` 的,名称由渲染点提供。 |
|
|
203
207
|
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
|
|
204
|
-
index
|
|
208
|
+
index 9547fb8576..0d28236dfb 100644
|
|
205
209
|
--- a/packages/client/ui-primitives/src/Menu.tsx
|
|
206
210
|
+++ b/packages/client/ui-primitives/src/Menu.tsx
|
|
207
211
|
@@ -1,5 +1,5 @@
|
|
208
212
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
209
|
-
-import type { CSSProperties, ReactNode } from 'react'
|
|
210
|
-
+import type { CSSProperties, ReactNode, RefObject } from 'react'
|
|
213
|
+
-import type { CSSProperties, ReactNode, SyntheticEvent } from 'react'
|
|
214
|
+
+import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react'
|
|
211
215
|
import { createPortal } from 'react-dom'
|
|
212
216
|
import clsx from 'clsx'
|
|
213
|
-
import {
|
|
214
|
-
@@ -
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
+ * @param props.boundaryRef - portal mode only:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
*
|
|
217
|
+
import { IconCheckOutlineRegular } from './icons/index.tsx'
|
|
218
|
+
@@ -137,6 +137,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
219
|
+
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
|
|
220
|
+
* wrapper there races the host's layout effects). Called on open, each animation
|
|
221
|
+
* frame, and scroll/resize; return null to skip placement for that frame.
|
|
222
|
+
+ * @param props.boundaryRef - portal mode only: intersect horizontal placement
|
|
223
|
+
+ * with this owner element's rect as well as the viewport, so a menu owned by a
|
|
224
|
+
+ * column stays inside that column when a sidebar or panel leaves a narrow
|
|
225
|
+
+ * center.
|
|
226
|
+
* @param props.footer - rows pinned below the scrolling items area, separated
|
|
227
|
+
* by a hairline; they stay visible while the items above scroll.
|
|
228
|
+
* @param props.children - component rows rendered after `items` in the same
|
|
229
|
+
@@ -153,7 +157,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
230
|
+
* document.body outside the owner's DOM subtree.
|
|
224
231
|
* @returns anchor wrapper with the conditional list.
|
|
225
232
|
*/
|
|
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 }: {
|
|
233
|
+
-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 }: {
|
|
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, boundaryRef, footer, className, listClassName }: {
|
|
228
235
|
open: boolean
|
|
229
236
|
autoFocus?: boolean
|
|
230
237
|
anchor: ReactNode
|
|
231
|
-
@@ -
|
|
238
|
+
@@ -172,6 +176,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
232
239
|
compact?: boolean
|
|
233
240
|
selection?: 'check' | 'fill'
|
|
234
241
|
getAnchorRect?: () => DOMRect | null
|
|
235
242
|
+ boundaryRef?: RefObject<HTMLElement | null>
|
|
236
243
|
className?: string | undefined
|
|
244
|
+
listClassName?: string | undefined
|
|
237
245
|
}) {
|
|
238
|
-
|
|
239
|
-
@@ -129,8 +131,13 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
|
|
246
|
+
@@ -240,8 +245,15 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
240
247
|
const MARGIN = 12
|
|
241
248
|
const vw = window.innerWidth
|
|
242
249
|
const vh = window.innerHeight
|
|
250
|
+
+ // The owner boundary narrows both clamp edges; without one the viewport
|
|
251
|
+
+ // alone decides, exactly as before.
|
|
243
252
|
+ const boundary = boundaryRef?.current?.getBoundingClientRect()
|
|
244
253
|
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
245
254
|
+ const maxX = Math.min(vw - MARGIN, (boundary?.right ?? vw) - MARGIN)
|
|
@@ -250,7 +259,7 @@ index db8b5588fe..cff56df8dc 100644
|
|
|
250
259
|
const lh = listEl?.offsetHeight ?? 0
|
|
251
260
|
|
|
252
261
|
let x: number
|
|
253
|
-
@@ -
|
|
262
|
+
@@ -253,14 +265,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
254
263
|
x = r.left
|
|
255
264
|
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
|
256
265
|
} else {
|
|
@@ -261,31 +270,35 @@ index db8b5588fe..cff56df8dc 100644
|
|
|
261
270
|
|
|
262
271
|
- if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
|
|
263
272
|
+ 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)
|
|
273
|
+
if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN)
|
|
265
274
|
|
|
266
|
-
- setFixedPos({ left: x, top: y })
|
|
267
|
-
+
|
|
268
|
-
+
|
|
269
|
-
+
|
|
270
|
-
+
|
|
271
|
-
+
|
|
275
|
+
- setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y })
|
|
276
|
+
+ // A bounded card takes the owner's width: the same 12px inset on both
|
|
277
|
+
+ // sides, never wider than the room between them.
|
|
278
|
+
+ const bounds = boundary === undefined
|
|
279
|
+
+ ? {}
|
|
280
|
+
+ : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth }
|
|
281
|
+
+ setFixedPos(current => current?.left === x && current.top === y
|
|
282
|
+
+ && current.minWidth === bounds.minWidth && current.maxWidth === bounds.maxWidth
|
|
283
|
+
+ ? current
|
|
284
|
+
+ : { left: x, top: y, ...bounds })
|
|
272
285
|
}
|
|
273
286
|
// First run measures the hidden pre-render (same commit as `open`), so
|
|
274
287
|
// end/top alignment and clamping use real dimensions before anything
|
|
275
|
-
@@ -
|
|
288
|
+
@@ -279,7 +299,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
276
289
|
window.removeEventListener('scroll', place, true)
|
|
277
290
|
window.removeEventListener('resize', place)
|
|
278
291
|
}
|
|
279
292
|
- }, [open, portal, align, side, getAnchorRect])
|
|
280
293
|
+ }, [open, portal, align, side, getAnchorRect, boundaryRef])
|
|
281
294
|
|
|
282
|
-
|
|
283
|
-
|
|
295
|
+
// Opening remembers where the keyboard was, so closing can hand it back to
|
|
296
|
+
// that control — an anchor wrapping several (a split button) cannot be asked
|
|
284
297
|
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
285
|
-
index
|
|
298
|
+
index a1933e216e..e324aaffb7 100644
|
|
286
299
|
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
287
300
|
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
288
|
-
@@ -
|
|
301
|
+
@@ -479,6 +479,33 @@ describe('Menu', () => {
|
|
289
302
|
expect(menu.style.top).toBe('132px')
|
|
290
303
|
})
|
|
291
304
|
|
|
@@ -307,6 +320,7 @@ index 70649d0874..c768b406ff 100644
|
|
|
307
320
|
+ onClose={() => {}}
|
|
308
321
|
+ />)
|
|
309
322
|
+ const menu = screen.getByRole('menu')
|
|
323
|
+
+ // The owner is 240px wide, so the 12px insets leave 216px for a 300px card.
|
|
310
324
|
+ expect(menu.style.left).toBe('292px')
|
|
311
325
|
+ expect(menu.style.minWidth).toBe('216px')
|
|
312
326
|
+ expect(menu.style.maxWidth).toBe('216px')
|