diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts index 3ec0b7dd45..7bd5720bc5 100644 --- a/apps/web/tests/model-menu-observer.e2e.ts +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -25,7 +25,7 @@ const PROVIDERS = [ ? { id: 'origin-large', name: 'Origin Large', reasoningEfforts: { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max', } } - : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}${index === 6 ? ' with a long name that exceeds the model card width' : ''}` }), })) const EXPECTED_GROUPS = [ { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, @@ -289,23 +289,33 @@ describe.skipIf(webSnapshotMode() === 'record').each([ expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) expect(tripwire.pageErrors).toEqual([]) }) - it.runIf(engine === chromium)('keeps the complete model card inside the zoomed visual viewport', async () => { + it.runIf(engine === chromium)('keeps the model card at layout size when zooming beyond the visual viewport', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-zoom')) const cdp = await page.context().newCDPSession(page) try { await page.setViewportSize({ width: 402, height: 874 }) await page.getByRole('button', { name: /^选择模型/ }).click() await page.getByRole('menuitem', { name: /^模型/ }).click() - await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + const panel = page.getByRole('group', { name: '模型与推理等级', exact: true }) for (const height of [874, 500]) { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await page.setViewportSize({ width: 402, height }) - await expect.poll(() => page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((panel) => { - const viewport = window.visualViewport! - const rect = panel.getBoundingClientRect() - return Math.min(rect.left - viewport.offsetLeft, rect.top - viewport.offsetTop, - viewport.offsetLeft + viewport.width - rect.right, viewport.offsetTop + viewport.height - rect.bottom) - })).toBeGreaterThanOrEqual(11.5) - expect(await page.evaluate(() => window.visualViewport!.scale)).toBeGreaterThan(2) + await frames(page) + const unzoomed = await panel.evaluate((node) => { + const rect = node.getBoundingClientRect() + const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect() + return { width: rect.width, height: rect.height, rightGap: rect.right - anchor.right, + bottomGap: anchor.top - rect.bottom } + }) + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5) + await expect.poll(() => panel.evaluate((node, before) => { + const rect = node.getBoundingClientRect() + const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect() + return Math.max(Math.abs(rect.width - before.width), Math.abs(rect.height - before.height), + Math.abs(rect.right - anchor.right - before.rightGap), Math.abs(anchor.top - rect.bottom - before.bottomGap)) + }, unzoomed)).toBeLessThan(1) + expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true) const models = page.getByRole('menu', { name: '模型', exact: true }) const names = models.locator('[data-model-id]').locator('..') expect(await names.evaluateAll(labels => labels.every(label => @@ -388,36 +398,68 @@ describe.skipIf(webSnapshotMode() === 'record').each([ } }) - it.runIf(engine === chromium)('scrolls to and selects the last reasoning effort after zooming', async () => { + it.runIf(engine === chromium)('keeps reasoning efforts selectable after zooming with and without list overflow', async () => { const cdp = await page.context().newCDPSession(page) try { - await page.setViewportSize({ width: 402, height: 500 }) - await page.getByRole('button', { name: /^选择模型/ }).click() - await page.getByRole('menuitem', { name: /^推理等级/ }).click() - await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) - const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true }) - const last = menu.getByRole('menuitemradio').last() - expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7) - await expect.poll(() => last.evaluate((row) => { - const scroller = row.parentElement! - scroller.scrollTop = scroller.scrollHeight - return { overflow: getComputedStyle(scroller).overflowY, moved: scroller.scrollTop > 0 } - })).toEqual({ overflow: 'auto', moved: true }) - await expect.poll(() => last.evaluate((row) => { - const rect = row.getBoundingClientRect() - const viewport = row.parentElement!.getBoundingClientRect() - return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1 - })).toBe(true) - // Dispatch the touch at the now-visible row; locator.click would auto-scroll even hidden overflow. - const point = await last.evaluate((row) => { - const rect = row.getBoundingClientRect() - const viewport = window.visualViewport! - return { x: rect.left + rect.width / 2 - viewport.offsetLeft, - y: rect.top + rect.height / 2 - viewport.offsetTop } - }) - await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) - await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' }) + for (const { height, scrolls } of [{ height: 500, scrolls: false }, { height: 350, scrolls: true }]) { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) + await page.setViewportSize({ width: 402, height }) + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^推理等级/ }).click() + const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true }) + await menu.getByRole('menuitemradio').first().click() + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().reasoningEffort).toBeUndefined() + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^推理等级/ }).click() + const last = menu.getByRole('menuitemradio').last() + await expect.poll(() => last.getAttribute('aria-checked')).toBe('false') + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5) + expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7) + await expect.poll(() => last.evaluate((row) => { + const scroller = row.parentElement! + scroller.scrollTop = scroller.scrollHeight + return { overflow: getComputedStyle(scroller).overflowY, + overflows: scroller.scrollHeight > scroller.clientHeight, moved: scroller.scrollTop > 0 } + })).toEqual({ overflow: 'auto', overflows: scrolls, moved: scrolls }) + await expect.poll(() => last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = row.parentElement!.getBoundingClientRect() + return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1 + })).toBe(true) + const target = await last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + return { + left: Math.max(0, Math.min(window.innerWidth - viewport.width, rect.left + rect.width / 2 - viewport.width / 2)), + top: Math.max(0, Math.min(window.innerHeight - viewport.height, rect.top + rect.height / 2 - viewport.height / 2)), + offsetLeft: viewport.offsetLeft, offsetTop: viewport.offsetTop, + } + }) + // Pan from the page margin so wheel input cannot scroll the list instead. + await cdp.send('Input.synthesizeScrollGesture', { + x: 1, y: 1, xDistance: target.offsetLeft - target.left, yDistance: target.offsetTop - target.top, + gestureSourceType: 'mouse', speed: 800, + }) + await expect.poll(() => last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + const x = rect.left + rect.width / 2 - viewport.offsetLeft + const y = rect.top + rect.height / 2 - viewport.offsetTop + return x >= 0 && x < viewport.width && y >= 0 && y < viewport.height + })).toBe(true) + // Dispatch touch without locator auto-scrolling the last row into view. + const point = await last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + return { x: rect.left + rect.width / 2 - viewport.offsetLeft, + y: rect.top + rect.height / 2 - viewport.offsetTop } + }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' }) + await menu.waitFor({ state: 'detached' }) + } } finally { await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await cdp.detach() diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index b4bb58e659..2ef017bfdb 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -21,8 +21,8 @@ en: cead8d55a9508a8f zh: d1ef63a880cbec55 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions: - en: 23610fb9daf9918a - zh: 9f63a97901be100c + en: 8da3af8ae98adbb3 + zh: b411b32d6b2b9e04 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 @@ -30,8 +30,8 @@ en: 80fc237fa5c829f7 zh: ecff5c8526f45996 /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation: - en: 14870a55b8f4d4b7 - zh: f498b469a785271e + en: 194aefd21d2d71fe + zh: 07d85f31c432e8b1 /deepseek-ai-dsh-client-ui-model-selection/further-exploration: en: 14acd4692e923480 zh: ff89aed852aa0da8 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 1a53aa7c7b..85dc6ea79e 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -49,7 +49,7 @@ The composer replaces the model and effort text with the Models icon when the ex `conversation.input.model.label` is a session-scoped, inline `single` slot for the composer trigger (`placement: 'trigger'`), the root menu's current value (`'menu'`), and each model option (`'option'`). Its owner props provide `provider: { id, name }`, the exact catalog `model`, `placement`, and `selected`. Provider names are localized display names; match channels by `provider.id`, and identify a model by the provider/model ID pair. Connection URLs and credentials are not passed to the slot. -Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name with its optional `model.badge` before the name in the trigger and after it in menus: a `Tag` in the badge's tone whose text is resolved for the active locale. Long catalog names ellipsize within the available width while the badge remains visible. A contribution replaces both and receives the badge in its owner props. The trigger and root-menu value use the slot only when the selected model is present in the displayed catalog; otherwise they keep the built-in placeholder. During refresh, retained catalog entries continue to use the slot. Open menus track label size changes to stay inside the viewport. `/model` command rows use the command popup's separate rendering API. +Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name with its optional `model.badge` before the name in the trigger and after it in menus: a `Tag` in the badge's tone whose text is resolved for the active locale. Long catalog names ellipsize within the available width while the badge remains visible. A contribution replaces both and receives the badge in its owner props. The trigger and root-menu value use the slot only when the selected model is present in the displayed catalog; otherwise they keep the built-in placeholder. During refresh, retained catalog entries continue to use the slot. The composer menu uses CSS anchors to follow its trigger and label size changes within the layout viewport. Pinch zoom scales the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. `/model` command rows use the command popup's separate rendering API. Import this package's `/client` declarations type-only, then register through `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))`. Derive `ModelLabel` props from `PropsRuntime<'conversation.input.model.label'>`; use provider IDs to choose CSS Module classes and retain `model.name` as the text. Registration follows the declaration and contributing plugin lifetimes. Without a component replacement, styles can target the enclosing label's `data-provider-id` and `data-model-id` attributes in all three placements. @@ -68,7 +68,7 @@ The session-scoped `conversation.input.model.options` list slot appends controls ## Understand the implementation -Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). The composer menu uses a fixed body portal. `useAnchoredPosition` normalizes the trigger's fixed-position coordinates and updates placement on scroll, resize, visual-viewport changes, and menu-size changes. Opening the menu and changing panes measure and place the panel synchronously before paint. Placement starts above the trigger with right edges aligned and an 8px gap; both axes shift to retain a 12px viewport margin without flipping below the trigger. Panel dimensions are capped to the visible viewport, including while the keyboard is open. Model names ellipsize within the panel width while badges stay visible. Model and reasoning-effort lists scroll vertically within the panel height limit. +Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). The composer menu uses a fixed body portal and CSS Anchor Positioning, which requires browser support for `anchor-name`, `position-anchor`, and `anchor()`. The trigger and menu share a unique anchor name. CSS keeps the menu above the trigger with right edges aligned and an 8px gap, allowing it to shrink within the layout viewport's 12px insets and desktop frame clearance. No JavaScript reads the trigger rectangle or tracks viewport changes for this placement. Pinch zoom scales the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Model names ellipsize within the panel width while badges stay visible. Model and reasoning-effort lists scroll vertically within the panel height limit.
Implementation internals — click to expand diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 253370b97f..77e8133a7b 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -49,7 +49,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重 `conversation.input.model.label` 是会话作用域的内联 `single` slot,覆盖 composer 触发按钮(`placement: 'trigger'`)、根菜单中的当前值(`'menu'`)和各模型选项(`'option'`)。owner props 提供 `provider: { id, name }`、准确的目录 `model`、`placement` 和 `selected`。提供方名称是本地化显示名称;按 `provider.id` 匹配渠道,按提供方与模型 ID 的组合识别模型。slot 不接收连接 URL 或凭据。 -扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称;模型带有目录角标 `model.badge` 时,触发按钮在名称前显示一个按角标语调着色的 `Tag`,菜单仍放在名称后,文字按当前语言解析。较长的目录名称会在可用宽度内省略,角标保持可见。扩展会同时替换两者,并从 owner props 收到角标。触发按钮和根菜单当前值仅在所选模型存在于显示目录中时调用 slot,否则保留内置占位文字。刷新期间保留的目录条目仍调用 slot。菜单打开时会跟随名称内容的尺寸变化调整位置,保持在视口内。`/model` 命令行使用命令弹窗独立的渲染 API。 +扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称;模型带有目录角标 `model.badge` 时,触发按钮在名称前显示一个按角标语调着色的 `Tag`,菜单仍放在名称后,文字按当前语言解析。较长的目录名称会在可用宽度内省略,角标保持可见。扩展会同时替换两者,并从 owner props 收到角标。触发按钮和根菜单当前值仅在所选模型存在于显示目录中时调用 slot,否则保留内置占位文字。刷新期间保留的目录条目仍调用 slot。输入框的模型菜单通过 CSS 锚点跟随触发按钮及标签大小变化,并按布局视口排版。双指缩放时菜单随页面一起缩放,可以超出视觉视口,包括键盘打开时。`/model` 命令行使用命令弹窗独立的渲染 API。 以 type-only 方式导入本包的 `/client` 声明,再通过 `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))` 注册。`ModelLabel` 的 props 从 `PropsRuntime<'conversation.input.model.label'>` 派生;用提供方 ID 选择 CSS Module 类,并保留 `model.name` 作为文字。注册随声明和贡献插件的生命周期清理。不替换组件时,样式也可以在三个显示位置通过名称外层的 `data-provider-id` 和 `data-model-id` 属性定位。 @@ -68,7 +68,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重 ## 理解实现 -菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。composer 菜单通过 body portal 使用 fixed 定位。`useAnchoredPosition` 统一触发按钮的 fixed 定位坐标,并在滚动、窗口缩放、可见视口变化和菜单尺寸变化时更新位置。打开菜单和切换面板时,在绘制前同步完成测量和定位。菜单默认在按钮上方展开、右边缘对齐、间距为 8px;两个方向均可移动以保留 12px 视口边距,但不会翻转到按钮下方。面板尺寸不超过可见视口,键盘打开时也遵循此限制。模型名称超出面板宽度时以省略号截断,角标保持可见。模型与推理等级列表在面板高度限制内纵向滚动。 +菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。composer 菜单通过 body portal 使用 fixed 定位及 CSS Anchor Positioning,要求浏览器支持 `anchor-name`、`position-anchor` 和 `anchor()`。触发按钮和菜单共享唯一的锚点名称。CSS 将菜单放在按钮上方,右边缘对齐、间距为 8px,并允许菜单在布局视口的 12px 内边距及桌面窗口边框间距内缩小。定位不通过 JavaScript 读取按钮矩形或追踪视口变化。双指缩放时菜单随页面一起缩放,可以超出视觉视口,包括键盘打开时。模型名称超出面板宽度时以省略号截断,角标保持可见。模型与推理等级列表在面板高度限制内纵向滚动。
实现细节——点击展开 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index fdd190fa4c..b75aca1bdf 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -7,6 +7,7 @@ 12px caption chevron; 28px chip height matches the sibling Plan / Read-only selects in the same tool row. */ .trigger { + anchor-name: var(--dsh-model-anchor); display: flex; align-items: center; gap: 4px; @@ -88,20 +89,28 @@ transform: rotate(180deg); } -/* Portaled to body and placed from the trigger rect by the component (the - Menu primitive's portal rules), so the sidebar and the columns' overflow - clips cannot crop it; z matches the primitive's .portal layer. */ +/* The body portal escapes overflow ancestors. CSS anchors keep the card above + its trigger; layout viewport bounds do not shrink during pinch zoom. */ .menu { box-sizing: border-box; position: fixed; + position-anchor: var(--dsh-model-anchor); + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + left: 12px; + right: max(12px, anchor(right)); + bottom: max(12px, calc(anchor(top) + 8px)); + align-self: safe end; + justify-self: safe end; + margin: 0; z-index: 1100; display: flex; flex-direction: column; /* Rows size the card up to its visible bounds; longer names ellipsize. */ - width: max-content; - min-width: min(240px, calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw)); - max-width: min(420px, calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw)); - max-height: min(360px, calc(100vh - 96px), var(--dsh-anchored-max-height, 100vh)); + width: fit-content; + height: fit-content; + min-width: min(240px, calc(100vw - 24px)); + max-width: min(420px, calc(100vw - 24px)); + max-height: min(360px, calc(100vh - 96px)); overflow: hidden; padding: 4px; /* Surface tokens match the Menu primitive card (ui-primitives diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 0369578a08..3eea3e37ad 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -27,7 +27,7 @@ * chevron, and each row whose value that selection carries shows one in place * of its check mark. */ -import { MenuGroup, MenuSurface, observeStickyMenuGroups, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives' +import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type FocusEvent, @@ -54,9 +54,6 @@ interface EffortChoice { label: string } -/** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */ -const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } - /** * Render the composer model seat. * @param props - owner share (locked) + injected face (shared directory @@ -89,18 +86,11 @@ export function ModelSelect( const searchRef = useRef(null) const menuRef = useRef(null) const groupsRef = useRef(null) - const menuPos = useAnchoredPosition({ - open: open && available, - anchorRef: triggerRef, - panelRef: menuRef, - side: 'top', - align: 'end', - gap: 8, - margin: 12, - layoutKey: pane, - }) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() + const anchorStyle: CSSProperties & Record<'--dsh-model-anchor', string> = { + '--dsh-model-anchor': `--dsh-model-${id.replaceAll(':', '')}`, + } const accountProviderName = t('provider.account') const groups = useMemo(() => orderModelProviders(state.groups).map(group => ({ @@ -455,6 +445,7 @@ export function ModelSelect( ref={triggerRef} type="button" className={css.trigger} + style={anchorStyle} aria-label={triggerAria} aria-haspopup="menu" aria-expanded={open} @@ -490,7 +481,7 @@ export function ModelSelect( ref={menuRef} id={`${id}-menu`} className={css.menu} - style={menuPos ?? MEASURE_STYLE} + style={anchorStyle} role={pane === 'model' ? 'group' : 'menu'} aria-label={t('menu.aria')} aria-busy={state.status === 'loading' || busy} 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 index 2807820dc5..7990890cec 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -450,41 +450,36 @@ describe('ModelSelect reasoning effort', () => { .map(row => row.textContent)).toEqual(['Max']) }) - it('portals the placed menu card to body and closes only on truly-outside mousedown', () => { - const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')! - const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 }) - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 }) - try { - const { container } = render() - const trigger = screen.getByRole('button', { name: /选择模型/ }) - fireEvent.click(trigger) - const menu = screen.getByRole('menu') - // Outside the composer subtree — column overflow clips cannot crop it. - expect(container.contains(menu)).toBe(false) - expect(menu.parentElement).toBe(document.body) - // jsdom anchor rects are all zero, so the measured 200x300 card clamps - // to the 12px viewport margin on both axes. - expect(menu.style.left).toBe('12px') - expect(menu.style.top).toBe('12px') - // Interactions inside the trigger subtree or the portaled card stay open. - expect(fireEvent.mouseDown(menu)).toBe(true) - expect(fireEvent.mouseDown(trigger)).toBe(false) - fireEvent.blur(trigger, { relatedTarget: menu }) - expect(screen.getByRole('menu')).toBeTruthy() - fireEvent.mouseDown(document.body) - expect(screen.queryByRole('menu')).toBeNull() - } finally { - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth) - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight) - } + it('anchors the body portal with CSS and closes only on truly-outside mousedown', () => { + const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + onTestFinished(() => { measure.mockRestore() }) + const { container } = render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + const menu = screen.getByRole('menu') + // Outside the composer subtree — column overflow clips cannot crop it. + expect(container.contains(menu)).toBe(false) + expect(menu.parentElement).toBe(document.body) + const anchor = trigger.style.getPropertyValue('--dsh-model-anchor') + expect(anchor).toMatch(/^--dsh-model-/) + expect(menu.style.getPropertyValue('--dsh-model-anchor')).toBe(anchor) + expect(menu.style.left).toBe('') + expect(menu.style.top).toBe('') + expect(measure).not.toHaveBeenCalled() + // Interactions inside the trigger subtree or the portaled card stay open. + expect(fireEvent.mouseDown(menu)).toBe(true) + expect(fireEvent.mouseDown(trigger)).toBe(false) + fireEvent.blur(trigger, { relatedTarget: menu }) + expect(screen.getByRole('menu')).toBeTruthy() + fireEvent.mouseDown(document.body) + expect(screen.queryByRole('menu')).toBeNull() }) it('renders no Agent-bound control for an addressed subagent session', () => {