@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.1.6-alpha.1",
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": "ddefc45fbc7f8e46dd73185e68295696d1297887",
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.0-rc.8"
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 2b2ba0e..bd61e45 100644
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
- @@ -150,6 +150,12 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
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 304bdb9..0e820ee 100644
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
- @@ -105,6 +105,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
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
- + const modelMenu = await page.getByRole('menu').boundingBox()
27
- + const modelCenter = await page.locator('[data-dsh-center-col]').boundingBox()
28
- + expect(modelMenu).not.toBeNull()
29
- + expect(modelCenter).not.toBeNull()
30
- + expect(modelMenu!.x).toBeGreaterThanOrEqual(modelCenter!.x + 11)
31
- + expect(modelMenu!.x + modelMenu!.width).toBeLessThanOrEqual(modelCenter!.x + modelCenter!.width - 11)
32
- await page.getByRole('menuitem', { name: /模型/ }).click()
33
- await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
34
-
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 582c5fb..bc4dc41 100644
37
+ index acc75b5..0a47e29 100644
37
38
  --- a/packages/client/ui-layout/README.md
38
39
  +++ b/packages/client/ui-layout/README.md
39
- @@ -45,7 +45,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
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 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.
44
- +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.
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 323318f..86a33a3 100644
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 b2cb715..968d000 100644
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
- @@ -104,7 +104,9 @@
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 b5a55f5..7252e44 100644
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
- @@ -167,13 +167,22 @@ export function ModelSelect(
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
- + setMenuPos({
99
- + left: x,
100
- + top: y,
101
- + ...boundary === undefined ? {} : { minWidth: Math.min(264, availableWidth), maxWidth: availableWidth },
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 4a5ee43..a70e88d 100644
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
- @@ -213,23 +213,28 @@ describe('ModelSelect reasoning effort', () => {
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 boundary is narrower than the viewport, so the card keeps
138
- + // the 12px center-column margin rather than spilling over the sidebar.
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 1736fd8..efd2496 100644
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
- @@ -100,6 +100,8 @@ export function PermissionSelect({
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
- + const triggerRef = useRef<HTMLButtonElement | null>(null)
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
- @@ -192,8 +194,13 @@ export function PermissionSelect({
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 cefa67e..915e570 100644
183
+ index 9ce34c7..15b8360 100644
179
184
  --- a/packages/client/ui-primitives/README.md
180
185
  +++ b/packages/client/ui-primitives/README.md
181
- @@ -40,7 +40,7 @@ Check this table before writing a control in a feature package. A plugin cannot
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
- -| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. 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. |
186
- +| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. 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. Portaled menus may intersect viewport clamping with an owner boundary. |
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
- | `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. |
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 3c069d0..39eeb5f 100644
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
- @@ -40,7 +40,7 @@ kind: "package-library"
195
- | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
196
- | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
197
- | `Input` | 单行文本输入,用于搜索框与行内表单。 |
198
- -| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
199
- +| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。portal菜单可将视口clamp与owner boundary取交集。 |
200
- | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
201
- | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
202
- | `StateDot` | 状态标记:`done`、`warning`、`ongoing`、`error` 或 `idle`。它是 `aria-hidden` 的,名称由渲染点提供。 |
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 bb04912..1e1e1c7 100644
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 { IconCheckOutline16 } from './icons/index.tsx'
214
- @@ -75,6 +75,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
- @@ -89,7 +90,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
223
- * crowds the cell).
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
- @@ -107,6 +108,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
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
- const rootRef = useRef<HTMLSpanElement>(null)
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
- @@ -187,14 +194,18 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
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
- + setFixedPos({
268
- + left: x,
269
- + top: y,
270
- + ...boundary === undefined ? {} : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth },
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
- @@ -206,7 +217,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
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 a2f6b70..458c825 100644
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
- @@ -416,6 +416,32 @@ describe('Menu', () => {
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')