@sparkelf/dsh-plus 0.2.1-rc.2.4 → 0.2.2-alpha.2.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/README.md +7 -5
- package/README.zh.md +7 -5
- package/lib/apply-08huHfLG.js +665 -0
- package/lib/apply-DmICvIGh.js +653 -0
- package/lib/apply.js +1 -1
- package/lib/bin.js +133 -37
- package/lib/types/apply.d.ts +1 -1
- package/lib/types/apply.js +56 -117
- package/lib/types/patch-set.d.ts +59 -0
- package/lib/types/patch-set.js +132 -0
- package/lib/types/standalone-cli.js +56 -2
- package/lib/types/standalone-profile.d.ts +8 -1
- package/lib/types/standalone-profile.js +84 -38
- package/package.json +625 -175
- package/patches/approval-allow-policy.patch +108 -0
- package/patches/atomic-write-zombie-lock-holder.patch +53 -0
- package/patches/bootstrap-exempt-names.patch +32 -0
- package/patches/browser-auth-mode.patch +127 -0
- package/patches/composer-popover-boundaries.patch +337 -0
- package/patches/diagram-preview.patch +562 -0
- package/patches/image-offload-hoist-replay.patch +533 -0
- package/patches/legacy-code-preset.patch +148 -0
- package/patches/llm-pi-ai-transport-truncation.patch +33 -0
- package/patches/mobile-journal-generation.patch +76 -0
- package/patches/office-preview-stable-load.patch +34 -0
- package/patches/officecli-deliverables.patch +42 -0
- package/patches/ptc-mcp-schema-types.patch +156 -0
- package/patches/responses-reasoning-status.patch +289 -0
- package/patches/session-envelope-ignorable.patch +63 -0
- package/patches/session-export-chinese.patch +85 -0
- package/patches/session-format-legacy-restart.patch +35 -0
- package/patches/session-log-trajectory-toolbar.patch +352 -0
- package/patches/state-dot-brand-blue.patch +185 -0
- package/patches/ui-settings-compact-phone.patch +76 -0
- package/patches/web-runtime-compat.patch +83 -0
- package/patches/workspace-storage-restore.patch +186 -0
- package/patches/wsl-native-open.patch +265 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
|
|
2
|
+
index f97978d346..edab518be0 100644
|
|
3
|
+
--- a/apps/web/tests/access-confirmation.e2e.ts
|
|
4
|
+
+++ b/apps/web/tests/access-confirmation.e2e.ts
|
|
5
|
+
@@ -151,6 +151,12 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
|
|
6
|
+
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:工作区内修改')
|
|
7
|
+
|
|
8
|
+
await access.click()
|
|
9
|
+
+ const permissionMenu = await page.getByRole('menu').boundingBox()
|
|
10
|
+
+ const permissionCenter = await page.locator('[data-dsh-center-col]').boundingBox()
|
|
11
|
+
+ expect(permissionMenu).not.toBeNull()
|
|
12
|
+
+ expect(permissionCenter).not.toBeNull()
|
|
13
|
+
+ expect(permissionMenu!.x).toBeGreaterThanOrEqual(permissionCenter!.x + 11)
|
|
14
|
+
+ expect(permissionMenu!.x + permissionMenu!.width).toBeLessThanOrEqual(permissionCenter!.x + permissionCenter!.width - 11)
|
|
15
|
+
await page.getByRole('menuitem', { name: '完全权限' }).click()
|
|
16
|
+
const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
|
|
17
|
+
await dialog.waitFor({ timeout: 10_000 })
|
|
18
|
+
diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts
|
|
19
|
+
index 9625333287..c71f8c02de 100644
|
|
20
|
+
--- a/apps/web/tests/default-model.e2e.ts
|
|
21
|
+
+++ b/apps/web/tests/default-model.e2e.ts
|
|
22
|
+
@@ -480,6 +480,13 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
|
23
|
+
await trigger.waitFor({ timeout: 15_000 })
|
|
24
|
+
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
|
|
25
|
+
await trigger.click()
|
|
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)
|
|
36
|
+
diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
|
|
37
|
+
index 43c61b89d1..8fa5117005 100644
|
|
38
|
+
--- a/packages/client/ui-layout/README.md
|
|
39
|
+
+++ b/packages/client/ui-layout/README.md
|
|
40
|
+
@@ -56,7 +56,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
|
|
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.
|
|
43
|
+
|
|
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, font-list, 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, font-list, 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.
|
|
46
|
+
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
50
|
+
index 5614947447..e3fc26d707 100644
|
|
51
|
+
--- a/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
52
|
+
+++ b/packages/client/ui-layout/src/client/AppFrame.tsx
|
|
53
|
+
@@ -33,7 +33,7 @@ export type AppFrameProps =
|
|
54
|
+
|
|
55
|
+
/** Center column grid item (session-body building block). */
|
|
56
|
+
function CenterColumn(props: { children?: ReactNode }) {
|
|
57
|
+
- return <div className={css.centerCol}>{props.children}</div>
|
|
58
|
+
+ return <div className={css.centerCol} data-dsh-center-col>{props.children}</div>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Subscribe to the main key without subscribing the column frame to each panel id. */
|
|
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
|
|
63
|
+
index c36809acaa..21fb6c73b8 100644
|
|
64
|
+
--- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
65
|
+
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
|
|
66
|
+
@@ -97,7 +97,9 @@
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
/* Let the visible pane's rows set the card width, but keep pathological ids
|
|
70
|
+
- and narrow screens inside a bounded card where ellipsis remains. */
|
|
71
|
+
+ and narrow screens inside a bounded card where ellipsis remains. The card's
|
|
72
|
+
+ own pad is part of that bound, as it is on the Menu primitive's surface. */
|
|
73
|
+
+ box-sizing: border-box;
|
|
74
|
+
width: max-content;
|
|
75
|
+
min-width: min(240px, calc(100vw - 32px));
|
|
76
|
+
max-width: min(420px, calc(100vw - 32px));
|
|
77
|
+
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
78
|
+
index f68d355317..c8a0089c19 100644
|
|
79
|
+
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
80
|
+
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
|
|
81
|
+
@@ -224,13 +224,25 @@ export function ModelSelect(
|
|
82
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
83
|
+
if (rect === undefined) return
|
|
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.
|
|
87
|
+
+ const boundary = triggerRef.current?.closest<HTMLElement>('[data-dsh-center-col]')?.getBoundingClientRect()
|
|
88
|
+
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
89
|
+
+ const maxX = Math.min(window.innerWidth - MARGIN, (boundary?.right ?? window.innerWidth) - MARGIN)
|
|
90
|
+
+ const availableWidth = Math.max(0, maxX - minX)
|
|
91
|
+
const lw = menuRef.current?.offsetWidth ?? 0
|
|
92
|
+
+ const placedWidth = boundary === undefined ? lw : Math.min(lw, availableWidth)
|
|
93
|
+
const lh = menuRef.current?.offsetHeight ?? 0
|
|
94
|
+
- let x = rect.right - lw
|
|
95
|
+
+ let x = rect.right - placedWidth
|
|
96
|
+
let y = rect.top - 8 - lh
|
|
97
|
+
- if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
|
|
98
|
+
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
99
|
+
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
|
|
100
|
+
- setMenuPos({ left: x, top: y })
|
|
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 })
|
|
107
|
+
}
|
|
108
|
+
place()
|
|
109
|
+
window.addEventListener('scroll', place, true)
|
|
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
|
|
111
|
+
index dbb5b247db..584e1f0366 100644
|
|
112
|
+
--- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
113
|
+
+++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
|
|
114
|
+
@@ -454,14 +454,19 @@ describe('ModelSelect reasoning effort', () => {
|
|
115
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
|
|
116
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
|
|
117
|
+
try {
|
|
118
|
+
- const { container } = render(<ModelSelect renderSlot={renderLabel}
|
|
119
|
+
+ const { container } = render(<div data-dsh-center-col><ModelSelect renderSlot={renderLabel}
|
|
120
|
+
locked={false}
|
|
121
|
+
available
|
|
122
|
+
directory={createSnapshotStore(state())}
|
|
123
|
+
load={vi.fn()} resolveText={resolveText}
|
|
124
|
+
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
|
|
125
|
+
t={t}
|
|
126
|
+
- />)
|
|
127
|
+
+ /></div>)
|
|
128
|
+
+ // The composer card's own rect is a zero rect in jsdom; the column's is not.
|
|
129
|
+
+ Object.defineProperty(container.firstElementChild, 'getBoundingClientRect', {
|
|
130
|
+
+ configurable: true,
|
|
131
|
+
+ value: () => ({ left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) }),
|
|
132
|
+
+ })
|
|
133
|
+
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
|
134
|
+
fireEvent.click(trigger)
|
|
135
|
+
const menu = screen.getByRole('menu')
|
|
136
|
+
@@ -469,8 +474,9 @@ describe('ModelSelect reasoning effort', () => {
|
|
137
|
+
expect(container.contains(menu)).toBe(false)
|
|
138
|
+
expect(menu.parentElement).toBe(document.body)
|
|
139
|
+
// jsdom anchor rects are all zero, so the measured 200x300 card clamps
|
|
140
|
+
- // to the 12px viewport margin on both axes.
|
|
141
|
+
- expect(menu.style.left).toBe('12px')
|
|
142
|
+
+ // to the centre column's 12px inset horizontally and the viewport's on
|
|
143
|
+
+ // the vertical axis.
|
|
144
|
+
+ expect(menu.style.left).toBe('292px')
|
|
145
|
+
expect(menu.style.top).toBe('12px')
|
|
146
|
+
// Interactions inside the trigger subtree or the portaled card stay open.
|
|
147
|
+
expect(fireEvent.mouseDown(menu)).toBe(true)
|
|
148
|
+
diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
149
|
+
index 0281c41a24..f44f20a8fc 100644
|
|
150
|
+
--- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
151
|
+
+++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
|
|
152
|
+
@@ -1,4 +1,4 @@
|
|
153
|
+
-import { useEffect, useState } from 'react'
|
|
154
|
+
+import { useEffect, useRef, useState } from 'react'
|
|
155
|
+
import type { ReactNode } from 'react'
|
|
156
|
+
import clsx from 'clsx'
|
|
157
|
+
import {
|
|
158
|
+
@@ -81,6 +81,8 @@ export function PermissionSelect({
|
|
159
|
+
const catalog = usePermissionCatalog(state => state.value)
|
|
160
|
+
const [pick, setPick] = useState<string | null>(null)
|
|
161
|
+
const [open, setOpen] = useState(false)
|
|
162
|
+
+ /** The center column this trigger sits in; the portaled menu clamps to it. */
|
|
163
|
+
+ const boundaryRef = useRef<HTMLElement | null>(null)
|
|
164
|
+
const [confirmation, setConfirmation] = useState<string | null>(null)
|
|
165
|
+
const [acknowledged, setAcknowledged] = useState(false)
|
|
166
|
+
|
|
167
|
+
@@ -171,8 +173,12 @@ export function PermissionSelect({
|
|
168
|
+
onClose={() => { setOpen(false) }}
|
|
169
|
+
side="top"
|
|
170
|
+
portal
|
|
171
|
+
+ boundaryRef={boundaryRef}
|
|
172
|
+
anchor={
|
|
173
|
+
<button
|
|
174
|
+
+ ref={(node) => {
|
|
175
|
+
+ boundaryRef.current = node?.closest<HTMLElement>('[data-dsh-center-col]') ?? null
|
|
176
|
+
+ }}
|
|
177
|
+
type="button"
|
|
178
|
+
className={css.trigger}
|
|
179
|
+
aria-label={t('mode', { name: currentLabel })}
|
|
180
|
+
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
|
|
181
|
+
index d95d802886..bb1d53df18 100644
|
|
182
|
+
--- a/packages/client/ui-primitives/README.md
|
|
183
|
+
+++ b/packages/client/ui-primitives/README.md
|
|
184
|
+
@@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
|
185
|
+
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
|
|
186
|
+
| `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. |
|
|
187
|
+
| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. |
|
|
188
|
+
-| `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. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. 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. |
|
|
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. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. 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. |
|
|
190
|
+
| `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. |
|
|
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. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
|
|
193
|
+
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
|
|
194
|
+
index 115e1defe5..9972ffe785 100644
|
|
195
|
+
--- a/packages/client/ui-primitives/README.zh.md
|
|
196
|
+
+++ b/packages/client/ui-primitives/README.zh.md
|
|
197
|
+
@@ -25,7 +25,7 @@ kind: "package-library"
|
|
198
|
+
<a id="use-this-package"></a>
|
|
199
|
+
## 使用本包
|
|
200
|
+
|
|
201
|
+
-`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
|
202
|
+
+`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 portal 模式的列表可把视口钳制与 owner boundary 取交集。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
|
203
|
+
|
|
204
|
+
`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
|
|
205
|
+
|
|
206
|
+
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
|
|
207
|
+
index 68b3a5766e..e25866591a 100644
|
|
208
|
+
--- a/packages/client/ui-primitives/src/Menu.tsx
|
|
209
|
+
+++ b/packages/client/ui-primitives/src/Menu.tsx
|
|
210
|
+
@@ -1,5 +1,5 @@
|
|
211
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
212
|
+
-import type { CSSProperties, ReactNode, SyntheticEvent } from 'react'
|
|
213
|
+
+import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react'
|
|
214
|
+
import { createPortal } from 'react-dom'
|
|
215
|
+
import clsx from 'clsx'
|
|
216
|
+
import { IconCheckOutlineRegular } from './icons/index.tsx'
|
|
217
|
+
@@ -154,6 +154,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
218
|
+
* wrapper there races the host's layout effects). Called on open, each animation
|
|
219
|
+
* frame, and scroll/resize; return null to skip placement for that frame.
|
|
220
|
+
* @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins.
|
|
221
|
+
+ * @param props.boundaryRef - portal mode only: intersect horizontal placement
|
|
222
|
+
+ * with this owner element's rect as well as the viewport, so a menu owned by a
|
|
223
|
+
+ * column stays inside that column when a sidebar or panel leaves a narrow
|
|
224
|
+
+ * center.
|
|
225
|
+
* @param props.footer - rows pinned below the scrolling items area, separated
|
|
226
|
+
* by a hairline; they stay visible while the items above scroll.
|
|
227
|
+
* @param props.children - component rows rendered after `items` in the same
|
|
228
|
+
@@ -170,7 +174,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
229
|
+
* document.body outside the owner's DOM subtree.
|
|
230
|
+
* @returns anchor wrapper with the conditional list.
|
|
231
|
+
*/
|
|
232
|
+
-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, minWidthFromAnchor = false, footer, className, listClassName }: {
|
|
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, minWidthFromAnchor = false, boundaryRef, footer, className, listClassName }: {
|
|
234
|
+
open: boolean
|
|
235
|
+
autoFocus?: boolean
|
|
236
|
+
anchor: ReactNode
|
|
237
|
+
@@ -190,6 +194,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
238
|
+
selection?: 'check' | 'fill'
|
|
239
|
+
getAnchorRect?: () => DOMRect | null
|
|
240
|
+
minWidthFromAnchor?: boolean
|
|
241
|
+
+ boundaryRef?: RefObject<HTMLElement | null>
|
|
242
|
+
className?: string | undefined
|
|
243
|
+
listClassName?: string | undefined
|
|
244
|
+
}) {
|
|
245
|
+
@@ -262,12 +267,19 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
246
|
+
const GAP = 4
|
|
247
|
+
const vw = window.innerWidth
|
|
248
|
+
const vh = window.innerHeight
|
|
249
|
+
+ // The owner boundary narrows both clamp edges; without one the viewport
|
|
250
|
+
+ // alone decides, exactly as before.
|
|
251
|
+
+ const boundary = boundaryRef?.current?.getBoundingClientRect()
|
|
252
|
+
+ const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
|
|
253
|
+
+ const maxX = Math.min(vw - MARGIN, (boundary?.right ?? vw) - MARGIN)
|
|
254
|
+
+ const availableWidth = Math.max(0, maxX - minX)
|
|
255
|
+
const listEl = listRef.current
|
|
256
|
+
if (listEl !== null) {
|
|
257
|
+
const minimum = minWidthFromAnchor ? `${Math.min(r.width, Math.max(0, vw - MARGIN * 2))}px` : ''
|
|
258
|
+
if (listEl.style.minWidth !== minimum) listEl.style.minWidth = minimum
|
|
259
|
+
}
|
|
260
|
+
const lw = listEl?.offsetWidth ?? 0
|
|
261
|
+
+ const placedWidth = boundary === undefined ? lw : Math.min(lw, availableWidth)
|
|
262
|
+
const lh = listEl?.offsetHeight ?? 0
|
|
263
|
+
|
|
264
|
+
let x: number
|
|
265
|
+
@@ -279,14 +291,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
266
|
+
x = r.left
|
|
267
|
+
y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP
|
|
268
|
+
} else {
|
|
269
|
+
- x = r.right - lw
|
|
270
|
+
+ x = r.right - placedWidth
|
|
271
|
+
y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
- if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
|
|
275
|
+
+ if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
|
|
276
|
+
if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN)
|
|
277
|
+
|
|
278
|
+
- setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y })
|
|
279
|
+
+ // A bounded card takes the owner's width: the same 12px inset on both
|
|
280
|
+
+ // sides, never wider than the room between them.
|
|
281
|
+
+ const bounds = boundary === undefined
|
|
282
|
+
+ ? {}
|
|
283
|
+
+ : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth }
|
|
284
|
+
+ setFixedPos(current => current?.left === x && current.top === y
|
|
285
|
+
+ && current.minWidth === bounds.minWidth && current.maxWidth === bounds.maxWidth
|
|
286
|
+
+ ? current
|
|
287
|
+
+ : { left: x, top: y, ...bounds })
|
|
288
|
+
}
|
|
289
|
+
// First run measures the hidden pre-render (same commit as `open`), so
|
|
290
|
+
// end/top alignment and clamping use real dimensions before anything
|
|
291
|
+
@@ -305,7 +325,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
|
292
|
+
window.removeEventListener('scroll', place, true)
|
|
293
|
+
window.removeEventListener('resize', place)
|
|
294
|
+
}
|
|
295
|
+
- }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor])
|
|
296
|
+
+ }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor, boundaryRef])
|
|
297
|
+
|
|
298
|
+
// Opening remembers where the keyboard was, so closing can hand it back to
|
|
299
|
+
// that control — an anchor wrapping several (a split button) cannot be asked
|
|
300
|
+
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
301
|
+
index 8b198e4365..592c56641d 100644
|
|
302
|
+
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
303
|
+
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
|
|
304
|
+
@@ -596,6 +596,33 @@ describe('Menu', () => {
|
|
305
|
+
expect(menu.style.top).toBe('132px')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
+ it('portal mode intersects viewport clamping with an owner boundary', () => {
|
|
309
|
+
+ const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
|
|
310
|
+
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 300 })
|
|
311
|
+
+ const anchor = { left: 480, right: 500, top: 100, bottom: 128, width: 20, height: 28, x: 480, y: 100, toJSON: () => ({}) } as DOMRect
|
|
312
|
+
+ const boundary = { left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) } as DOMRect
|
|
313
|
+
+ try {
|
|
314
|
+
+ render(<Menu
|
|
315
|
+
+ portal
|
|
316
|
+
+ open
|
|
317
|
+
+ align="end"
|
|
318
|
+
+ getAnchorRect={() => anchor}
|
|
319
|
+
+ boundaryRef={{ current: { getBoundingClientRect: () => boundary } as HTMLElement }}
|
|
320
|
+
+ anchor={null}
|
|
321
|
+
+ items={items}
|
|
322
|
+
+ onSelect={() => {}}
|
|
323
|
+
+ onClose={() => {}}
|
|
324
|
+
+ />)
|
|
325
|
+
+ const menu = screen.getByRole('menu')
|
|
326
|
+
+ // The owner is 240px wide, so the 12px insets leave 216px for a 300px card.
|
|
327
|
+
+ expect(menu.style.left).toBe('292px')
|
|
328
|
+
+ expect(menu.style.minWidth).toBe('216px')
|
|
329
|
+
+ expect(menu.style.maxWidth).toBe('216px')
|
|
330
|
+
+ } finally {
|
|
331
|
+
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
|
|
332
|
+
+ }
|
|
333
|
+
+ })
|
|
334
|
+
+
|
|
335
|
+
it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => {
|
|
336
|
+
render(
|
|
337
|
+
<Menu
|