@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.6-alpha.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": "0a15e36e7f82b6ed45af6fa9759f29b40dcd965d",
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.2"
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 cd64d98110..f513b1114a 100644
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
- @@ -53,6 +53,12 @@ describe('web e2e: Full access confirmation', () => {
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 304bdb97f6..0e820ee757 100644
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 4fe19fb9ec..976a4dad8c 100644
36
+ index b26b182f5e..55391c0bb3 100644
70
37
  --- a/packages/client/ui-layout/README.md
71
38
  +++ b/packages/client/ui-layout/README.md
72
- @@ -43,7 +43,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
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 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.
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 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.
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 05bb4b9494..c156255193 100644
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 b2cb715287..968d000bc5 100644
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
- @@ -104,7 +104,9 @@
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 e9fc0195ef..637ecb5601 100644
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
- @@ -137,13 +137,22 @@ export function ModelSelect(
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
- + setMenuPos({
132
- + left: x,
133
- + top: y,
134
- + ...boundary === undefined ? {} : { minWidth: Math.min(264, availableWidth), maxWidth: availableWidth },
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 3b8c92d8bf..53b1f03c39 100644
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
- @@ -206,23 +206,28 @@ describe('ModelSelect reasoning effort', () => {
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 boundary is narrower than the viewport, so the card keeps
171
- + // the 12px center-column margin rather than spilling over the sidebar.
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 1919e923c6..fe35b2a1f4 100644
182
+ index 3328454710..2255803273 100644
179
183
  --- a/packages/client/ui-primitives/README.md
180
184
  +++ b/packages/client/ui-primitives/README.md
181
- @@ -39,7 +39,7 @@ Check this table before writing a control in a feature package. A plugin cannot
182
- | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
183
- | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
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 menus may intersect viewport clamping with an owner boundary. |
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 e0c90fd14a..31011edb72 100644
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
- @@ -39,7 +39,7 @@ kind: "package-library"
195
- | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
196
- | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
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` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;portal菜单可将视口clamp与owner boundary取交集。 |
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 db8b5588fe..cff56df8dc 100644
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 { IconCheckOutline16 } from './icons/index.tsx'
214
- @@ -70,6 +70,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
215
- * gap and a brief overshoot survivable; coming back cancels the close.
216
- * @param props.dense - reduce vertical row spacing without changing the standard typography or card width.
217
- * @param props.compact - use reduced menu typography and spacing.
218
- + * @param props.boundaryRef - portal mode only: limit horizontal placement to this owner as well as the viewport.
219
- * @param props.getAnchorRect - portal mode only: supply the anchor rect
220
- * directly (e.g. from a host-owned trigger button) instead of measuring the
221
- * Menu's own wrapper span. Required when the wrapper isn't itself laid out at
222
- @@ -84,7 +85,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
223
- * crowds the cell).
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
- @@ -102,6 +103,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
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
- const rootRef = useRef<HTMLSpanElement>(null)
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
- @@ -142,14 +149,18 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
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
- + setFixedPos({
268
- + left: x,
269
- + top: y,
270
- + ...boundary === undefined ? {} : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth },
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
- @@ -161,7 +172,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
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
- useEffect(() => {
283
- if (open && autoFocus) listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus()
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 70649d0874..c768b406ff 100644
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
- @@ -337,6 +337,32 @@ describe('Menu', () => {
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')