From dd124c5790e56b4e9619e4b414bf97d7b902f4bb Mon Sep 17 00:00:00 2001 From: Stevring Date: Fri, 30 Oct 2026 17:24:57 +0800 Subject: [PATCH 1/6] fix(client): position model picker with CSS anchors --- apps/web/tests/model-menu-observer.e2e.ts | 118 ++++++++++++------ .../ui-model-selection/README.i18n.yaml | 8 +- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.module.css | 23 ++-- .../src/client/ModelSelect.tsx | 21 +--- .../tests/model-select.client.spec.tsx | 65 +++++----- 7 files changed, 140 insertions(+), 103 deletions(-) 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', () => { From 2c990f0b14fbe870cd6a083c1708b35fd22a7172 Mon Sep 17 00:00:00 2001 From: Stevring Date: Mon, 2 Nov 2026 14:22:17 +0800 Subject: [PATCH 2/6] refactor(client): position floating panels with CSS anchors --- apps/web/tests/context-menus.e2e.ts | 33 ++- apps/web/tests/default-workspace.e2e.ts | 26 +- apps/web/tests/jobs-menu-layering.e2e.ts | 6 +- apps/web/tests/schedule-after.e2e.ts | 37 +++ apps/web/tests/schedule-pickers.e2e.ts | 115 ++++++-- .../css-anchored-menus/guide.i18n.yaml | 16 ++ .../css-anchored-menus/guide.md | 21 ++ .../css-anchored-menus/guide.zh.md | 21 ++ packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/StatsPills.tsx | 8 +- .../src/client/chat/TurnUsagePanel.tsx | 8 +- .../src/client/chat/stat-dialog.module.css | 47 +++- .../ui-chat/src/client/chat/stat-dialog.ts | 37 +-- .../ui-context-menu/src/client/View.tsx | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ContextMeter.module.css | 34 ++- .../src/client/skeleton/ContextMeter.tsx | 17 +- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 2 +- packages/client/ui-jobs/README.zh.md | 2 +- .../src/client/JobListAction.module.css | 38 ++- .../ui-jobs/src/client/JobListAction.tsx | 24 +- .../tests/job-list-action.client.spec.tsx | 71 ++--- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.module.css | 34 ++- .../src/client/PluginManagerPage.tsx | 14 +- .../client/ui-primitives/README.i18n.yaml | 16 +- packages/client/ui-primitives/README.md | 7 +- packages/client/ui-primitives/README.zh.md | 7 +- .../client/ui-primitives/src/JsonTree.tsx | 2 +- .../client/ui-primitives/src/Menu.module.css | 84 ++++-- packages/client/ui-primitives/src/Menu.tsx | 119 +++++---- packages/client/ui-primitives/src/index.ts | 2 - .../ui-primitives/src/useAnchoredPosition.ts | 120 --------- .../ui-primitives/tests/atoms.client.spec.tsx | 245 +++++++----------- .../tests/context-menu.client.spec.tsx | 37 ++- .../menu-anchor-visibility.client.spec.tsx | 75 ++++-- .../tests/menu-anchor-width.client.spec.tsx | 60 ++--- .../tests/menu-styles.client.spec.ts | 4 +- .../tests/menu-top-clearance.client.spec.tsx | 68 ++--- .../use-anchored-position.client.spec.tsx | 236 ----------------- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../src/client/ClockPicker.module.css | 4 +- .../ui-schedule/src/client/ClockPicker.tsx | 5 +- .../ui-schedule/src/client/DatePicker.tsx | 5 +- .../src/client/PickerPopover.module.css | 47 +++- .../ui-schedule/src/client/PickerPopover.tsx | 14 +- .../client/ScheduleCatalogAction.module.css | 43 ++- .../src/client/ScheduleCatalogAction.tsx | 16 +- .../src/client/TaskEditorFields.tsx | 10 +- .../src/client/TaskManagerPage.module.css | 2 +- .../src/client/TaskMenu.module.css | 84 ++++-- .../ui-schedule/src/client/TaskMenu.tsx | 48 +--- .../schedule-catalog-action.client.spec.tsx | 11 +- .../tests/task-menu.client.spec.tsx | 19 +- .../tests/timing-pickers.client.spec.tsx | 18 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/office/FontNotice.module.css | 34 ++- .../src/client/office/FontNotice.tsx | 13 +- packages/client/ui-subagent/README.i18n.yaml | 8 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- .../client/SubagentHeaderLineage.module.css | 28 +- .../src/client/SubagentHeaderLineage.tsx | 9 +- .../tests/conversation-ui.client.spec.tsx | 50 ++-- .../src/client/WorkspacePicker.tsx | 9 +- .../session-inspector/README.i18n.yaml | 4 +- .../experimental/session-inspector/README.md | 2 +- .../session-inspector/README.zh.md | 2 +- .../src/client/views/TypeFilter.tsx | 13 +- .../src/client/views/inspector.module.css | 32 ++- 84 files changed, 1231 insertions(+), 1054 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md delete mode 100644 packages/client/ui-primitives/src/useAnchoredPosition.ts delete mode 100644 packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 4d797fe340..78d959375b 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -330,27 +330,46 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) }) - it('keeps context-menu rows reachable after browser zoom', async () => { + it('keeps context-menu layout size during browser zoom and scrolls overflowing rows', async () => { const cdp = await page.context().newCDPSession(page) try { await page.setViewportSize({ width: 1291, height: 480 }) await page.locator('[data-row-key="session:context-menu-first"]').click({ button: 'right' }) const menu = page.getByRole('menu') await menu.waitFor() - // Desktop Chromium caps page scale at three; the short window makes the rows overflow at that scale. + const unzoomed = await menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(3) - await expect.poll(() => menu.evaluate((node) => { + await expect.poll(() => menu.evaluate((node, before) => { + const rect = node.getBoundingClientRect() + return Math.max(Math.abs(rect.x - before.x), Math.abs(rect.y - before.y), + Math.abs(rect.width - before.width), Math.abs(rect.height - before.height)) + }, unzoomed)).toBeLessThan(1) + expect(await menu.evaluate((node) => { const view = window.visualViewport! - const box = node.getBoundingClientRect() - return Math.min(box.left - view.offsetLeft, box.top - view.offsetTop, - view.offsetLeft + view.width - box.right, view.offsetTop + view.height - box.bottom) - })).toBeGreaterThanOrEqual(11.5) + const rect = node.getBoundingClientRect() + return rect.width > view.width || rect.height > view.height + })).toBe(true) + + // A smaller layout viewport, rather than page zoom alone, makes this list overflow. + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) + await page.setViewportSize({ width: 1291, height: 180 }) const rows = menu.locator('[role="presentation"]').first() + await expect.poll(() => rows.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true) + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) expect(await rows.evaluate((node) => { node.scrollTop = node.scrollHeight return node.scrollTop > 0 })).toBe(true) + await expect.poll(() => rows.evaluate((node) => { + const last = [...node.querySelectorAll('[role="menuitem"]')].at(-1)! + const row = last.getBoundingClientRect() + const viewport = node.getBoundingClientRect() + return row.top >= viewport.top - 1 && row.bottom <= viewport.bottom + 1 + })).toBe(true) } finally { await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await cdp.detach() diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index 51ddce2312..35b663ff74 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium, webkit } from 'playwright' import { describe, expect, it, onTestFinished } from 'vitest' import { assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, @@ -70,6 +70,30 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { } }) + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'opens the default Workspace chooser from its external trigger ($name)', async ({ engine }) => { + const scaffold = await launchWebScaffold({ firstUse: true }) + onTestFinished(() => scaffold.close()) + const browser = await engine.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, locale: 'en-US', timezoneId: 'Asia/Shanghai', + isMobile: true, hasTouch: true, + }) + await page.goto(scaffold.authenticatedUrl) + const trigger = page.getByRole('button', { name: 'Choose workspace', exact: true }) + await expect.poll(() => trigger.textContent()).toBe('Default workspace') + await trigger.tap() + const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Default workspace', exact: true }) }) + await menu.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await trigger.evaluate(node => node.style.anchorName)).toBe(await menu.evaluate(node => node.style.positionAnchor)) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'hidden' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + }, + ) + it('reports a startup directory conflict and opens the composed folder picker for recovery', async () => { const scaffold = await launchWebScaffold({ firstUse: true }) onTestFinished(() => scaffold.close()) diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index ca2f53e227..3a4eda2f70 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -47,13 +47,13 @@ function placement(page: Page) { [0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint( left + (right - left) * x, top + (bottom - top) * y, )))) - const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12) - const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12) + const verticalGap = Math.min(Math.abs(box.top - trigger.bottom - 5), Math.abs(trigger.top - box.bottom - 5)) + const horizontalAlignment = Math.min(Math.abs(box.left - trigger.left), Math.abs(box.right - trigger.right)) return { dockMode: dock.dataset.sidebarRightPanel, bodyMounted: menu.parentElement === document.body, fixed: getComputedStyle(menu).position === 'fixed', - anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1, + anchored: verticalGap <= 1 && horizontalAlignment <= 1, viewportBounded: box.left >= 11 && box.top >= 11 && box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11, overlapsBrowser, diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 34cbaf4155..2b39f5e41e 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -920,6 +920,43 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps a wide schedule catalog below its header trigger in a narrow viewport', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-anchor')) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'Check exact cadence', exact: true }).click() + await manager.getByRole('button', { name: /^Linked session.*: open linked session$/ }).click() + await liveAgent(scaffold, CATALOG_SESSION_ID) + const collapse = page.getByRole('button', { name: 'Collapse sidebar', exact: true }) + if (await collapse.isVisible()) await collapse.click() + await page.setViewportSize({ width: 360, height: 586 }) + await page.evaluate(() => document.fonts.ready) + const trigger = page.getByRole('button', { name: '3 automation tasks', exact: true }) + await trigger.click() + const catalog = page.getByRole('list', { name: 'Enabled automation tasks', exact: true }) + await expect.poll(() => catalog.evaluate((node) => { + const anchor = document.querySelector('button[aria-label="3 automation tasks"]')!.getBoundingClientRect() + const rect = node.getBoundingClientRect() + return { + gap: Math.round(rect.top - anchor.bottom), + inside: rect.left >= 15.5 && rect.right <= innerWidth - 15.5 + && rect.top >= 15.5 && rect.bottom <= innerHeight - 15.5, + widerThanEitherSide: rect.width > innerWidth - 16 - anchor.left && rect.width > anchor.right - 16, + } + })).toEqual({ gap: 5, inside: true, widerThanEitherSide: true }) + expect(await catalog.getByRole('listitem').count()).toBe(3) + await page.keyboard.press('Escape') + await catalog.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(node => node === document.activeElement)).toBe(true) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 900, height: 900 }) + const expand = page.getByRole('button', { name: 'Open sidebar', exact: true }) + if (await expand.isVisible()) await expand.click() + } + }) + it('reads stored tasks without a live Session and deletes them through the catalog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog')) const layers = [ diff --git a/apps/web/tests/schedule-pickers.e2e.ts b/apps/web/tests/schedule-pickers.e2e.ts index 2548f71777..cb0531c9da 100644 --- a/apps/web/tests/schedule-pickers.e2e.ts +++ b/apps/web/tests/schedule-pickers.e2e.ts @@ -1,5 +1,5 @@ /** Zoomed automation pickers truncate wide labels and keep vertical choices reachable. */ -import type { Browser, Locator, Page } from 'playwright' +import type { Browser, CDPSession, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-schedule' @@ -12,7 +12,7 @@ let browser: Browser beforeAll(async () => { scaffold = await launchWebScaffold() - // Two digits must overflow at 500% zoom; narrower system UI fonts can fit. + // Long zone labels use the same font on every host. await scaffold.ctx.settings.update('ui-theme', { textFontFamily: 'monospace' }) await scaffold.ctx.schedule.create({ title: TITLE, prompt: 'Inspect the deployment log', @@ -30,30 +30,87 @@ afterAll(async () => { } }) -/** Assert that the complete floating card stays inside the zoomed visual viewport. */ -async function expectContained(panel: Locator): Promise { - await expect.poll(() => panel.evaluate((node) => { - const view = window.visualViewport! +/** Pan the visual viewport to a row, then touch it without locator auto-scroll. */ +async function touchRow(cdp: CDPSession, row: Locator): Promise { + const target = await row.evaluate((node) => { const rect = node.getBoundingClientRect() - return Math.min(rect.left - view.offsetLeft, rect.top - view.offsetTop, - view.offsetLeft + view.width - rect.right, view.offsetTop + view.height - rect.bottom) - })).toBeGreaterThanOrEqual(11.5) + const view = window.visualViewport! + return { + left: Math.max(0, Math.min(window.innerWidth - view.width, rect.left + rect.width / 2 - view.width / 2)), + top: Math.max(0, Math.min(window.innerHeight - view.height, rect.top + rect.height / 2 - view.height / 2)), + offsetLeft: view.offsetLeft, offsetTop: view.offsetTop, + } + }) + 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(() => row.evaluate((node) => { + const rect = node.getBoundingClientRect() + const view = window.visualViewport! + const x = rect.left + rect.width / 2 - view.offsetLeft + const y = rect.top + rect.height / 2 - view.offsetTop + return x >= 0 && x < view.width && y >= 0 && y < view.height + })).toBe(true) + const point = await row.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { x: rect.left + rect.width / 2 - window.visualViewport!.offsetLeft, + y: rect.top + rect.height / 2 - window.visualViewport!.offsetTop } + }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } -/** Narrow labels ellipsize instead of increasing their row's width. */ -async function expectEllipsizedText(labels: Locator): Promise { +/** Labels retain ellipsis styling without increasing the list's width. */ +async function expectEllipsisStyle(labels: Locator): Promise { expect(await labels.evaluateAll(nodes => nodes.every((node) => { const style = getComputedStyle(node) return style.textOverflow === 'ellipsis' && style.overflowX === 'hidden' }))).toBe(true) - expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true) } describe.each([ - { locale: 'en-US', tasks: 'Automation tasks', time: 'Time', second: 'Second', zone: 'Time zone' }, - { locale: 'zh-CN', tasks: '自动化任务', time: '时间', second: '秒', zone: '时区' }, + { locale: 'en-US', tasks: 'Automation tasks', time: 'Time', second: 'Second', zone: 'Time zone', repeat: 'Repeat' }, + { locale: 'zh-CN', tasks: '自动化任务', time: '时间', second: '秒', zone: '时区', repeat: '重复' }, ])('automation pickers ($locale)', (copy) => { - it.each(['clock', 'zone'] as const)('ellipsizes the %s labels and keeps vertical choices reachable at 500% zoom', async (kind) => { + it('keeps the repeat menu inside the short layout viewport before and after zoom', async () => { + const context = await browser.newContext({ + viewport: { width: 360, height: 586 }, locale: copy.locale, timezoneId: 'Asia/Shanghai', + isMobile: true, hasTouch: true, + }) + const page = await context.newPage() + try { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await revealNavigationDrawer(page, copy.locale === 'zh-CN' ? 'zh' : 'en') + await page.getByRole('button', { name: copy.tasks, exact: true }).click() + const detail = page.getByTestId('task-manager-page') + await detail.getByRole('button', { name: TITLE, exact: true }).click() + await page.evaluate(() => document.fonts.ready) + await detail.getByRole('button', { name: new RegExp(`^${copy.repeat}`) }).click() + const menu = page.getByRole('menu') + await expect.poll(() => menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return Math.min(rect.left, rect.top, innerWidth - rect.right, innerHeight - rect.bottom) + })).toBeGreaterThanOrEqual(11.5) + const unzoomed = await menu.boundingBox() + expect(unzoomed).not.toBeNull() + const cdp = await context.newCDPSession(page) + try { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 }) + await expect.poll(() => page.evaluate(() => visualViewport!.scale)).toBe(5) + await expect.poll(() => menu.boundingBox()).toEqual(unzoomed) + } finally { + await cdp.detach() + } + } catch (error) { + await saveFailureShot(page, `schedule-repeat-bounds-${copy.locale}`) + throw error + } finally { + await context.close() + } + }) + + it.each(['clock', 'zone'] as const)('keeps %s layout size, clipping and vertical choices reachable at 500% zoom', async (kind) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: copy.locale, timezoneId: 'Asia/Shanghai', isMobile: true, hasTouch: true, @@ -74,15 +131,28 @@ describe.each([ const panel = kind === 'clock' ? page.getByRole('dialog', { name: copy.time, exact: true }) : page.getByRole('menu') + const before = await panel.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { width: rect.width, height: rect.height, left: rect.left, top: rect.top } + }) const cdp = await context.newCDPSession(page) await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 }) await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(5) - await expectContained(panel) + await expect.poll(() => panel.evaluate((node, original) => { + const rect = node.getBoundingClientRect() + return Math.max(Math.abs(rect.width - original.width), Math.abs(rect.height - original.height), + Math.abs(rect.left - original.left), Math.abs(rect.top - original.top)) + }, before)).toBeLessThan(1) + expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true) expect(await panel.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) if (kind === 'clock') { - await expectEllipsizedText(panel.locator('[role="option"] > span')) + await expectEllipsisStyle(panel.locator('[role="option"] > span')) const columns = panel.getByRole('listbox') + expect(await columns.evaluateAll(nodes => nodes.every((node) => { + const style = getComputedStyle(node) + return style.overscrollBehaviorX === 'auto' && style.overscrollBehaviorY === 'contain' + }))).toBe(true) expect(await columns.evaluateAll(nodes => nodes.every(node => node.scrollWidth <= node.clientWidth))).toBe(true) const seconds = panel.getByRole('listbox', { name: copy.second, exact: true }) @@ -97,10 +167,15 @@ describe.each([ return label.left >= column.left && label.right <= column.right && label.top >= column.top && label.bottom <= column.bottom })).toBe(true) - await last.click() + await touchRow(cdp, last) await expect.poll(() => trigger.textContent()).toMatch(/:59$/) } else { - await expectEllipsizedText(panel.locator('[role="menuitem"] > span')) + const labels = panel.locator('[role="menuitem"] > span') + await expectEllipsisStyle(labels) + // Long English names exercise overflow; translated labels may fit the layout-sized menu. + if (copy.locale === 'en-US') { + expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true) + } const viewport = panel.locator('[role="presentation"]').last() expect(await viewport.evaluate((node) => { node.scrollTop = node.scrollHeight @@ -113,7 +188,7 @@ describe.each([ return row.left >= viewport.left && row.right <= viewport.right && row.top >= viewport.top && row.bottom <= viewport.bottom })).toBe(true) - await last.click() + await touchRow(cdp, last) await panel.waitFor({ state: 'hidden' }) } expect(consoleWatch.pageErrors).toEqual([]) diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml new file mode 100644 index 0000000000..3805e25d52 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md +/: + en: bdb7c51789ea8d61 + zh: 5bd9ab5abbf48670 +/css-anchored-menus: + en: 2d9b87ac50fc94bb + zh: 97f8c85f921b3793 +/css-anchored-menus/change: + en: 6361b58498cf3297 + zh: 4e1d21372bec95a9 +/css-anchored-menus/migration: + en: 7dba5d8b6e6bdc4a + zh: d97a7c13086e9a00 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md new file mode 100644 index 0000000000..bcb19d01bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Client UI consumers must replace JavaScript panel positioning and Menu rectangle callbacks with CSS anchors." +--- + +# CSS-anchored menus + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-client-ui-primitives` removes the `useAnchoredPosition` value export and `AnchoredPositionOptions` type export. `Menu` replaces `getAnchorRect` with `anchorRef` for an external trigger element or `anchorPoint` for a point-triggered menu. Custom Client UI that imports the removed exports or passes the removed prop must migrate. + +Portaled menus follow their anchors through CSS Anchor Positioning. Their size limits use the layout viewport; browser zoom and panning move them with the page, and the panels may extend beyond the visual viewport, including while the keyboard is open. Menu dismissal, keyboard navigation, and internal scrolling remain available. The browser must support the CSS anchor positioning properties used by the component. + +## Migration + +1. Remove imports of `useAnchoredPosition` and `AnchoredPositionOptions`. For a custom panel, give its trigger a unique `anchor-name` and set the panel's `position-anchor` to the same name. Define placement and size limits in CSS; see the [model selector](../../../../packages/client/ui-model-selection/README.md#understand-the-implementation). +2. When `Menu.getAnchorRect` returned a trigger's rectangle, pass the trigger's element ref as `anchorRef` instead. An inline `anchor` rendered by `Menu` needs no external ref. +3. When the callback returned a zero-size rectangle for a pointer gesture, pass `{ x, y }` as `anchorPoint` in the same client-viewport coordinates as `event.clientX`/`clientY` or `getBoundingClientRect()`. `Menu` performs the Safari-to-layout conversion when opening or changing the point; callers must not add the visual-viewport offset themselves. +4. Rebuild the custom Client UI and check that no removed import or prop remains. Verify opening, scrolling, Escape, focus restoration, and selection before and after browser zoom. Assertions must allow visual-viewport overflow while retaining layout-viewport bounds and content access. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md new file mode 100644 index 0000000000..615d38906a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Client UI 使用方必须将 JavaScript 面板定位和 Menu 矩形回调替换为 CSS 锚点。" +--- + +# CSS 锚定菜单 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-primitives` 移除 `useAnchoredPosition` 值导出和 `AnchoredPositionOptions` 类型导出。`Menu` 将 `getAnchorRect` 替换为指向外部触发元素的 `anchorRef`,或用于按坐标打开菜单的 `anchorPoint`。导入已移除导出或传入已移除 prop 的自定义 Client UI 必须迁移。 + +使用 portal 的菜单通过 CSS Anchor Positioning 跟随锚点。尺寸限制采用布局视口;浏览器缩放和平移时菜单随页面移动,面板可以超出视觉视口,包括键盘打开时。菜单关闭、键盘导航和内部滚动保持可用。浏览器必须支持组件使用的 CSS 锚点定位属性。 + +## 迁移 + +1. 移除 `useAnchoredPosition` 和 `AnchoredPositionOptions` 导入。自定义面板为触发元素设置唯一的 `anchor-name`,将面板的 `position-anchor` 设置为相同名称,并用 CSS 定义位置和尺寸限制;参见[模型选择器](../../../../packages/client/ui-model-selection/README.zh.md#understand-the-implementation)。 +2. 原 `Menu.getAnchorRect` 返回触发元素矩形时,改为通过 `anchorRef` 传入该元素的 ref。由 `Menu` 渲染的内联 `anchor` 无需外部 ref。 +3. 原回调为指针手势返回零尺寸矩形时,改为通过 `anchorPoint` 传入 `{ x, y }`,坐标与 `event.clientX`/`clientY` 或 `getBoundingClientRect()` 使用相同的客户端视口坐标系。`Menu` 在打开或坐标变化时执行 Safari 到布局视口的转换;调用方不要自行叠加视觉视口偏移。 +4. 重新构建自定义 Client UI,确认不再使用已移除的导入或 prop。在浏览器缩放前后验证打开、滚动、Escape、焦点恢复和选择。断言应允许超出视觉视口,同时保留布局视口约束及内容访问能力。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 54168bf257..45b056511e 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: c5b23d0ed29a0425 zh: 689bab2c8253294a /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: cfdfe3d99d3f4e09 - zh: 9330bad3d3f7a689 + en: d363057b5c0b2571 + zh: 0f6fe864d9673576 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: 382821ca3ffa60dd zh: 9840ffa07c13082a diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b23b9dc3e1..e62a2645e8 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -78,7 +78,7 @@ A completed Turn shows an expandable usage row only when the loaded window inclu Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. -The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. +The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. Statistics dialogs use CSS anchors and layout-viewport size limits; browser zoom and panning move them with the page, so they may extend beyond the visual viewport. Taller dialog content scrolls internally. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index e5f8add05b..54d7f34def 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -78,7 +78,7 @@ Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。统计对话框通过 CSS 锚点定位,并按布局视口限制尺寸;浏览器缩放和平移时随页面移动,因此可以超出视觉视口。较高的对话框内容可在内部滚动。 明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 7108c44719..dd5060ff5c 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -17,7 +17,7 @@ import type { ChatSnapshot } from '../contract/snapshot.ts' import { formatTokensPerSecond } from './message-chrome.ts' import { assistantStepReading } from '../contract/turn-metrics.ts' import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' -import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts' +import { useStatDialog } from './stat-dialog.ts' import css from './StatsPills.module.css' import dialogCss from './stat-dialog.module.css' @@ -203,9 +203,9 @@ function DialogPill({ stat, icon, label, text, title, titleValue, children }: Pi titleValue?: string children: ReactNode }) { - const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() + const { open, setOpen, rootRef, panelRef, anchorName } = useStatDialog() return ( - + {/* The open dialog already shows every figure; a tooltip would cover it. */} + {}} />) const menu = screen.getByRole('menu') - // side=bottom, align=start: below the host-supplied rect, left-aligned. - expect(menu.style.left).toBe('40px') - expect(menu.style.top).toBe('132px') + expect(menu.style.positionAnchor).not.toBe('') + expect(menu.style.positionAnchor).toBe(anchor.current?.style.anchorName) + expect(menu.style.positionAnchor).not.toBe('--existing-anchor') + expect(menu.style.left).toBe('') + expect(menu.style.top).toBe('') + view.rerender(<> + {}} />) + expect(anchor.current?.style.anchorName).toBe('--existing-anchor') }) - it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => { - render( - null} - anchor={null} - items={items} - onSelect={() => {}} - onClose={() => {}} - />) + it('waits for an external anchor before displaying the portal menu', () => { + const anchor = createRef() + render( {}} />) expect(screen.queryByRole('menu')).toBeNull() }) - it('shows and focuses a portal menu when its host anchor becomes available', () => { - vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }) - let rect: DOMRect | null = null + it('focuses once an external anchor mounts and preserves row focus while it moves', () => { + const anchor = createRef() + function Fixture({ present, margin = 0 }: { present: boolean; margin?: number }) { + return <>{present && } + {}} /> + } + const geometry = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') try { - render( rect} anchor={null} - items={[...items, { id: 'c', label: 'Gamma' }]} onClose={() => {}} />) + const view = render() expect(screen.queryByRole('menu')).toBeNull() - rect = new DOMRect(40, 100, 32, 28) - act(() => { vi.advanceTimersToNextFrame() }) - expect(screen.getByRole('menu').style.left).toBe('40px') + view.rerender() + const name = screen.getByRole('menu').style.positionAnchor + expect(name).toBe(anchor.current?.style.anchorName) expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' })) screen.getByRole('menuitem', { name: 'Gamma' }).focus() - rect = new DOMRect(80, 100, 32, 28) - act(() => { vi.advanceTimersToNextFrame() }) - expect(screen.getByRole('menu').style.left).toBe('80px') + view.rerender() + expect(screen.getByRole('menu').style.positionAnchor).toBe(name) expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Gamma' })) - } finally { - cleanup() - vi.useRealTimers() - } + expect(geometry).not.toHaveBeenCalled() + } finally { geometry.mockRestore() } }) - it('keeps a portal menu aligned through WebKit zoom and visual-viewport panning', () => { + it('uses a replacement external ref for inside clicks, keyboard entry and focus return', () => { + const first = createRef() + const second = createRef() + const onClose = vi.fn() + function Fixture({ useSecond }: { useSecond: boolean }) { + return <> + + } + const view = render() + view.rerender() + fireEvent.pointerDown(second.current!) + expect(onClose).not.toHaveBeenCalled() + fireEvent.pointerDown(first.current!) + expect(onClose).toHaveBeenCalledTimes(1) + second.current!.focus() + fireEvent.keyDown(second.current!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(document.activeElement).toBe(second.current) + }) + + it('leaves browser zoom and viewport panning to CSS without geometry reads or viewport listeners', () => { const viewport = Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 }) vi.stubGlobal('visualViewport', viewport) - vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) - vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) - const remove = vi.spyOn(viewport, 'removeEventListener') - const ui = render( new DOMRect(100, 200, 200, 20)} - anchor={null} items={items} onClose={() => {}} />) - const menu = screen.getByRole('menu') - expect(menu.style.left).toBe('220px') - expect(menu.style.top).toBe('284px') - act(() => { viewport.dispatchEvent(new Event('resize')) }) - expect(menu.style.left).toBe('220px') - expect(menu.style.top).toBe('284px') - act(() => { - viewport.offsetLeft = 100 - viewport.offsetTop = 120 - viewport.dispatchEvent(new Event('scroll')) - }) - expect(menu.style.left).toBe('280px') - expect(menu.style.top).toBe('344px') - act(() => { - viewport.height = 200 - viewport.dispatchEvent(new Event('resize')) - }) - expect(menu.style.top).toBe('228px') - ui.unmount() - expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['scroll', 'resize'])) + const add = vi.spyOn(viewport, 'addEventListener') + const geometry = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + try { + const view = render(Open} items={items} onClose={() => {}} />) + const menu = screen.getByRole('menu') + const name = menu.style.positionAnchor + expect(name).not.toBe('') + act(() => { + viewport.offsetLeft = 100 + viewport.offsetTop = 120 + viewport.height = 200 + viewport.dispatchEvent(new Event('scroll')) + viewport.dispatchEvent(new Event('resize')) + }) + expect(menu.style.positionAnchor).toBe(name) + expect(menu.style.left).toBe('') + expect(menu.style.top).toBe('') + expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe('') + expect(geometry).not.toHaveBeenCalled() + expect(add).not.toHaveBeenCalled() + view.unmount() + } finally { + geometry.mockRestore() + add.mockRestore() + vi.unstubAllGlobals() + } }) - it('follows an anchor moving without scroll or resize and stops tracking when closed or unmounted', () => { - vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }) - let rect = new DOMRect(40, 100, 32, 28) - const getAnchorRect = vi.fn(() => rect) - const props = { portal: true, anchor: null, items, getAnchorRect, onClose: () => {} } - try { - const view = render() - rect = new DOMRect(140, 180, 32, 28) - act(() => { vi.advanceTimersToNextFrame() }) - expect(screen.getByRole('menu').style.left).toBe('140px') - expect(screen.getByRole('menu').style.top).toBe('212px') - - view.rerender() - getAnchorRect.mockClear() - act(() => { vi.advanceTimersToNextFrame() }) - expect(getAnchorRect).not.toHaveBeenCalled() - - view.rerender() - view.unmount() - getAnchorRect.mockClear() - act(() => { vi.advanceTimersToNextFrame() }) - expect(getAnchorRect).not.toHaveBeenCalled() - } finally { - cleanup() - vi.useRealTimers() - } + it('gives separate portal menus independent CSS anchors', () => { + const view = render(<> + First} items={items} onClose={() => {}} /> + Second} items={items} onClose={() => {}} /> + ) + const menus = screen.getAllByRole('menu') + const first = screen.getByRole('button', { name: 'First' }).parentElement! + const second = screen.getByRole('button', { name: 'Second' }).parentElement! + expect(menus[0]!.style.positionAnchor).toBe(first.style.anchorName) + expect(menus[1]!.style.positionAnchor).toBe(second.style.anchorName) + expect(menus[0]!.style.positionAnchor).not.toBe(menus[1]!.style.positionAnchor) + view.unmount() }) it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => { @@ -766,7 +763,7 @@ describe('Menu', () => { // Outside the anchor wrapper subtree — overflow-clipping ancestors can't crop it. expect(container.contains(menu)).toBe(false) expect(menu.parentElement).toBe(document.body) - expect(menu.style.top).not.toBe('') + expect(menu.style.positionAnchor).not.toBe('') fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) expect(onSelect).toHaveBeenCalledWith('a') fireEvent.pointerDown(menu) @@ -780,14 +777,14 @@ describe('Menu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => { - render( - trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + it('passes top-end placement to CSS without writing measured coordinates', () => { + render(trigger} + items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') - expect(menu.style.left).not.toBe('') - expect(menu.style.top).not.toBe('') - expect(menu.style.right).toBe('') - expect(menu.style.bottom).toBe('') + expect(menu.getAttribute('data-anchor-side')).toBe('top') + expect(menu.getAttribute('data-anchor-align')).toBe('end') + expect(menu.style.positionAnchor).not.toBe('') + for (const edge of ['left', 'top', 'right', 'bottom'] as const) expect(menu.style[edge]).toBe('') }) it('renders footer rows in a pinned section below the items; they still select', () => { @@ -823,51 +820,7 @@ describe('Menu', () => { expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) }) - it('keeps the near viewport margin when the list cannot fit the visible viewport', () => { - // jsdom lays nothing out, so the list's own size and the viewport it has to - // fit inside are stubbed: this is the tall workspace list on a phone, where - // the browser chrome leaves less height than the list needs. - const height = vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(900) - const width = vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(900) - vi.stubGlobal('innerHeight', 600) - vi.stubGlobal('innerWidth', 600) - try { - render( {}} onClose={() => {}} />) - const menu = screen.getByRole('menu') - expect(menu.style.top).toBe('12px') - expect(menu.style.left).toBe('12px') - } finally { - height.mockRestore() - width.mockRestore() - vi.unstubAllGlobals() - } - }) - it('clamps inside the visual viewport where the browser reports a smaller one', () => { - // Mobile browser chrome: the visual viewport is what the user sees, while - // window.innerHeight keeps the taller layout viewport. - const height = vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) - vi.stubGlobal('innerHeight', 900) - vi.stubGlobal('innerWidth', 900) - vi.stubGlobal('visualViewport', Object.assign(new EventTarget(), { width: 500, height: 500, offsetLeft: 0, offsetTop: 0 })) - try { - render( - new DOMRect(10, 700, 40, 20)} - anchor={null} - items={items} - onSelect={() => {}} - onClose={() => {}} - />) - // 500 - 400 - 12, not the layout viewport's 900. - expect(screen.getByRole('menu').style.top).toBe('88px') - } finally { - height.mockRestore() - vi.unstubAllGlobals() - } - }) }) describe('Modal', () => { diff --git a/packages/client/ui-primitives/tests/context-menu.client.spec.tsx b/packages/client/ui-primitives/tests/context-menu.client.spec.tsx index ffef90d4dd..2e1540ce06 100644 --- a/packages/client/ui-primitives/tests/context-menu.client.spec.tsx +++ b/packages/client/ui-primitives/tests/context-menu.client.spec.tsx @@ -5,16 +5,19 @@ import { afterEach, expect, it, vi } from 'vitest' import { Menu, MenuItemButton } from '../src/Menu.tsx' import { ReferenceChip } from '../src/user-text.tsx' -afterEach(() => { vi.restoreAllMocks(); cleanup(); document.documentElement.style.removeProperty('--dsh-frame-top-clearance') }) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); cleanup(); document.documentElement.style.removeProperty('--dsh-frame-top-clearance') }) it('places pointer menus at the cursor above the frame inset and focuses after portal placement', async () => { document.documentElement.style.setProperty('--dsh-frame-top-clearance', '96px') const close = vi.fn() const view = render( new DOMRect(200, 20, 0, 0)} items={[{ id: 'copy', label: 'Copy' }]} />) + anchorPoint={{ x: 200, y: 20 }} items={[{ id: 'copy', label: 'Copy' }]} />) const menu = view.getByRole('menu') - expect(menu.style.top).toBe('24px') - expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe(`${window.innerHeight - 24}px`) + const point = view.container.querySelector('[data-menu-point-anchor]')! + expect(point.style.left).toBe('200px') + expect(point.style.top).toBe('20px') + expect(menu.style.positionAnchor).toBe(point.style.anchorName) + expect(menu.style.top).toBe('') await waitFor(() => { expect(document.activeElement).toBe(view.getByRole('menuitem')) }) fireEvent.pointerDown(document.body, { button: 2 }) expect(close).not.toHaveBeenCalled() @@ -22,6 +25,32 @@ it('places pointer menus at the cursor above the frame inset and focuses after p expect(close).toHaveBeenCalledOnce() }) +it.each([false, true])('captures the point in layout coordinates once with visual-origin client coordinates=%s', (visualOrigin) => { + const viewport = Object.assign(new EventTarget(), { offsetLeft: 40, offsetTop: 60 }) + const subscribe = vi.spyOn(viewport, 'addEventListener') + vi.stubGlobal('visualViewport', viewport) + vi.stubGlobal('CSS', { supports: (property: string) => visualOrigin && property === '-webkit-backdrop-filter' }) + const props = { portal: true, contextual: true, anchor: null, onClose: vi.fn(), + items: [{ id: 'copy', label: 'Copy' }] } + const view = render() + const point = (): HTMLElement => view.container.querySelector('[data-menu-point-anchor]')! + expect(point().style.left).toBe(visualOrigin ? '240px' : '200px') + expect(point().style.top).toBe(visualOrigin ? '160px' : '100px') + viewport.offsetLeft = 80 + viewport.offsetTop = 120 + view.rerender() + expect(point().style.left).toBe(visualOrigin ? '240px' : '200px') + expect(point().style.top).toBe(visualOrigin ? '160px' : '100px') + view.rerender() + view.rerender() + expect(point().style.left).toBe(visualOrigin ? '280px' : '200px') + expect(point().style.top).toBe(visualOrigin ? '220px' : '100px') + view.rerender() + expect(point().style.left).toBe(visualOrigin ? '300px' : '220px') + expect(point().style.top).toBe(visualOrigin ? '250px' : '130px') + expect(subscribe).not.toHaveBeenCalled() +}) + it('groups data icons per divider, preserves component rows, and makes exit frames inert', () => { const props = { open: true, portal: true, contextual: true, anchor: null, onClose: vi.fn(), items: [{ id: 'first', label: 'First', icon: }, { id: 'second', label: 'Second' }, diff --git a/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx b/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx index 711a5f834b..19d5f844e0 100644 --- a/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx @@ -2,20 +2,13 @@ /** A portalled menu follows its anchor's interactive lifetime. */ import { useRef, useState } from 'react' import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { Menu } from '../src/Menu.tsx' -let frames: Map -beforeEach(() => { - frames = new Map() - let next = 0 - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++next, callback); return next }) - vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) -}) -afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) it.each(['inert', 'hidden'].flatMap(attribute => [false, true].map(contextual => ({ attribute, contextual }))))( - 'closes when the retained anchor becomes $attribute with contextual=$contextual', ({ attribute, contextual }) => { + 'closes when the retained anchor becomes $attribute with contextual=$contextual', async ({ attribute, contextual }) => { function Fixture() { const [open, setOpen] = useState(true) const trigger = useRef(null) @@ -27,13 +20,60 @@ it.each(['inert', 'hidden'].flatMap(attribute => [false, true].map(contextual => } const view = render() expect(screen.getByRole('menu')).toBeTruthy() - view.getByTestId('owner').setAttribute(attribute, '') - act(() => { for (const callback of [...frames.values()]) callback(0) }) + await act(async () => { view.getByTestId('owner').setAttribute(attribute, ''); await Promise.resolve() }) expect(screen.queryByRole('menu')).toBeNull() expect(view.getByTestId('owner').isConnected).toBe(true) }) -it('does not restart a contextual dismissal while its original control remains inert', () => { +it('closes when an external anchor leaves the document', async () => { + const anchor = document.createElement('button') + const anchorRef = { current: anchor } + document.body.append(anchor) + function Fixture() { + const [open, setOpen] = useState(true) + return { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + } + render() + expect(screen.getByRole('menu')).toBeTruthy() + await act(async () => { anchor.remove(); await Promise.resolve() }) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it.each(['hidden', 'inert'])('tracks %s on an external anchor that mounts after opening', async (attribute) => { + function Fixture({ ready }: { ready: boolean }) { + const [open, setOpen] = useState(true) + const anchor = useRef(null) + return <>{ready && } + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + } + const view = render() + expect(screen.queryByRole('menu')).toBeNull() + view.rerender() + expect(screen.getByRole('menu')).toBeTruthy() + await act(async () => { screen.getByRole('button').setAttribute(attribute, ''); await Promise.resolve() }) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it('retargets visibility when an open menu receives a replacement element through the same ref', async () => { + function Fixture({ generation }: { generation: number }) { + const [open, setOpen] = useState(true) + const anchor = useRef(null) + return <> + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + } + const view = render() + const original = screen.getByRole('button') + await act(async () => { view.rerender(); await Promise.resolve() }) + expect(original.isConnected).toBe(false) + expect(screen.getByRole('menu')).toBeTruthy() + await act(async () => { screen.getByRole('button').setAttribute('hidden', ''); await Promise.resolve() }) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it('does not restart a contextual dismissal while its original control remains inert', async () => { const close = vi.fn() function Fixture() { const [exiting, setExiting] = useState<'dismiss'>() @@ -44,13 +84,12 @@ it('does not restart a contextual dismissal while its original control remains i } const view = render() const menu = screen.getByRole('menu') - const top = menu.style.top - view.getByTestId('owner').setAttribute('inert', '') - act(() => { for (const callback of [...frames.values()]) callback(0) }) - act(() => { for (const callback of [...frames.values()]) callback(0) }) + const anchorName = menu.style.positionAnchor + await act(async () => { view.getByTestId('owner').setAttribute('inert', ''); await Promise.resolve() }) + await act(async () => { view.getByTestId('owner').setAttribute('hidden', ''); await Promise.resolve() }) expect(close).toHaveBeenCalledTimes(1) expect(menu.style.visibility).not.toBe('hidden') - expect(menu.style.top).toBe(top) + expect(menu.style.positionAnchor).toBe(anchorName) }) it.each([undefined, 'existing-panel'])('restores the trigger aria-controls value %s after closing', (previous) => { diff --git a/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx b/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx index c99ce22592..00b880b062 100644 --- a/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx @@ -1,48 +1,50 @@ // @vitest-environment jsdom -/** Portal menus reserve their anchor width before measuring end alignment. */ +/** Portal menus delegate minimum width and viewport limits to CSS. */ +import { useRef } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { Menu } from '../src/Menu.tsx' afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) -function measureAnchor() { - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(120, 40, 280, 22)) - vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.getAttribute('role') === 'menu' ? Math.max(120, Number.parseFloat(this.style.minWidth) || 0) : 280 - }) - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(40) -} - describe('Menu anchor minimum width', () => { - it('matches the anchor and clears its minimum when the option is removed', () => { - measureAnchor() + it('registers only the external trigger as its anchor', () => { + function Fixture() { + const trigger = useRef(null) + return <> + + } + const view = render() + const trigger = view.getByRole('button', { name: 'Tools' }) + const name = view.getByRole('menu').style.positionAnchor + expect(name).not.toBe('') + expect([...document.querySelectorAll('[style]')] + .filter(node => node.style.anchorName === name)).toEqual([trigger]) + }) + + it('enables anchor-width matching and clears it when the option is removed', () => { const props = { open: true, portal: true, align: 'end' as const, anchor: , onClose: vi.fn() } const view = render() - expect(view.getByRole('menu').style.minWidth).toBe('280px') - expect(view.getByRole('menu').style.left).toBe('120px') + const menu = view.getByRole('menu') + expect(menu.hasAttribute('data-match-anchor-width')).toBe(true) + expect(menu.style.minWidth).toBe('') + expect(menu.style.positionAnchor).not.toBe('') view.rerender() - expect(view.getByRole('menu').style.minWidth).toBe('') + expect(view.getByRole('menu').hasAttribute('data-match-anchor-width')).toBe(false) }) - it('keeps the minimum inside the viewport margins when the anchor is wider', () => { - measureAnchor() - vi.stubGlobal('innerWidth', 200) - const view = render(Tools} - items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) - expect(view.getByRole('menu').style.minWidth).toBe('176px') - expect(view.getByRole('menu').style.left).toBe('12px') - }) - - it('bounds the anchor minimum by the visual viewport after browser zoom', () => { - measureAnchor() - vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) + it('keeps matching declarative when the visual viewport is narrower than its anchor', () => { + const readRect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') vi.stubGlobal('visualViewport', Object.assign(new EventTarget(), { width: 200, height: 400, offsetLeft: 40, offsetTop: 60, })) - const view = render(Tools} + const view = render(Tools} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) - expect(view.getByRole('menu').style.minWidth).toBe('176px') - expect(view.getByRole('menu').style.left).toBe('52px') + const menu = view.getByRole('menu') + expect(menu.hasAttribute('data-match-anchor-width')).toBe(true) + expect(menu.style.minWidth).toBe('') + expect(menu.style.left).toBe('') + expect(readRect).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/tests/menu-styles.client.spec.ts b/packages/client/ui-primitives/tests/menu-styles.client.spec.ts index 61f7446205..7651ec9070 100644 --- a/packages/client/ui-primitives/tests/menu-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/menu-styles.client.spec.ts @@ -23,8 +23,8 @@ describe('Menu.module.css', () => { // The fallback first: a browser that drops `dvh` keeps the layout-viewport // cap, and mobile browser chrome can then only hide the card's far edge. expect(maxHeights()).toEqual([ - 'min(calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh))', - 'min(calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh))', + 'calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)))', + 'calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)))', ]) }) diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx index 6e1700e7f9..135a55540e 100644 --- a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx @@ -1,55 +1,31 @@ -// @vitest-environment jsdom -/** Portal menus keep the desktop frame's top clearance without leaving a gap below an anchor inside it. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' -import { Menu } from '../src/Menu.tsx' +/** The window fallback reserves desktop chrome while direct anchor placement keeps its normal gap. */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' -const root = document.documentElement -let prior = '' - -beforeEach(() => { - prior = root.style.getPropertyValue('--dsh-frame-top-clearance') - root.style.setProperty('--dsh-frame-top-clearance', '48px') -}) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() - if (prior === '') root.style.removeProperty('--dsh-frame-top-clearance') - else root.style.setProperty('--dsh-frame-top-clearance', prior) -}) - -/** - * Open an 80px-tall portal menu from an anchor with the stated rect. - * @param anchor - the anchor rect in viewport coordinates. - * @param side - which anchor edge the menu opens from. - * @returns the render result. - */ -function openMenu(anchor: DOMRect, side: 'top' | 'bottom') { - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor) - vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) - return render(Open} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) -} +const css = readFileSync(new URL('../src/Menu.module.css', import.meta.url), 'utf8') describe('Menu top clearance', () => { - it('opens directly below an anchor that sits inside the top strip', () => { - const view = openMenu(new DOMRect(300, 14, 18, 22), 'bottom') - expect(view.getByRole('menu').style.top).toBe('40px') + it('uses the frame overlay inset when no anchor-side placement fits', () => { + const fallback = /@position-try --dsh-menu-window\s*\{([^}]+)\}/.exec(css)?.[1] + expect(fallback).toBeDefined() + expect(fallback).toMatch(/top:\s*var\(--dsh-menu-window-top\)/) + expect(css).toMatch(/\.portal\s*\{[^}]*--dsh-menu-window-top:\s*max\(12px, var\(--dsh-frame-overlay-top, 12px\)\)/) + expect(fallback).toMatch(/bottom:\s*12px/) }) - it('keeps a menu that does not fit above its anchor below the top strip', () => { - const view = openMenu(new DOMRect(300, 100, 18, 22), 'top') - expect(view.getByRole('menu').style.top).toBe('68px') + it('starts direct placement at the anchor with the normal four-pixel gap', () => { + const portal = /\.portal\s*\{([^}]+)\}/.exec(css)?.[1] + expect(portal).toBeDefined() + expect(portal).toMatch(/position-area:\s*bottom span-right/) + expect(portal).toMatch(/margin:\s*4px 0 0/) + expect(portal).toMatch(/position-try-fallbacks:[^;]*flip-block[^;]*--dsh-menu-window/) }) - it('places a menu that does not fit above an anchor inside the top strip directly below it', () => { - const view = openMenu(new DOMRect(300, 14, 18, 22), 'top') - expect(view.getByRole('menu').style.top).toBe('40px') - }) - - it('keeps the top strip clear when the anchor has scrolled above the viewport', () => { - const view = openMenu(new DOMRect(300, -30, 18, 22), 'bottom') - expect(view.getByRole('menu').style.top).toBe('68px') + it('keeps pointer-menu height independent of desktop frame clearance', () => { + const contextual = /\.list\[data-context-menu\]\.scrollable\s*\{([^}]+)\}/.exec(css)?.[1] + expect(contextual).toBeDefined() + expect(contextual).toContain('calc(100dvh - 24px)') + expect(contextual).not.toContain('--dsh-frame-overlay-top') + expect(css).toMatch(/\.portal\[data-context-menu\]\s*\{\s*--dsh-menu-window-top:\s*12px;/) }) }) diff --git a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx deleted file mode 100644 index df02d5a43a..0000000000 --- a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx +++ /dev/null @@ -1,236 +0,0 @@ -// @vitest-environment jsdom -/** Synchronous fixed placement across viewport changes and anchor movement. */ -import { useRef } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render, renderHook } from '@testing-library/react' -import { useAnchoredPosition } from '../src/useAnchoredPosition.ts' - -let frames: FrameRequestCallback[] -beforeEach(() => { - frames = [] - vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => frames.push(callback))) - vi.stubGlobal('cancelAnimationFrame', vi.fn()) - vi.stubGlobal('innerWidth', 1000) - vi.stubGlobal('innerHeight', 800) - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 300, 200, 20)) - vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) - vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) -}) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -function Host({ open, ...options }: Omit[0], 'anchorRef' | 'panelRef' | 'gap' | 'margin'>) { - const anchorRef = useRef(null) - const panelRef = useRef(null) - const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...options }) - return <> - - {open &&
} - -} - -function show(props: Parameters[0]) { - return render() -} - -describe('useAnchoredPosition', () => { - it.each(['anchor', 'panel'] as const)('does not measure or subscribe without the %s element', (missing) => { - const anchor = document.createElement('button') - const measure = vi.spyOn(anchor, 'getBoundingClientRect') - const { result } = renderHook(() => useAnchoredPosition({ - open: true, - anchorRef: { current: missing === 'anchor' ? null : anchor }, - panelRef: { current: missing === 'panel' ? null : document.createElement('div') }, - gap: 4, - margin: 12, - })) - expect(result.current).toBeNull() - expect(measure).not.toHaveBeenCalled() - expect(requestAnimationFrame).not.toHaveBeenCalled() - }) - - it('does not position elements from a document without a window', () => { - const detachedDocument = document.implementation.createHTMLDocument() - const { result } = renderHook(() => useAnchoredPosition({ - open: true, - anchorRef: { current: detachedDocument.createElement('button') }, - panelRef: { current: detachedDocument.createElement('div') }, - gap: 4, - margin: 12, - })) - expect(result.current).toBeNull() - expect(requestAnimationFrame).not.toHaveBeenCalled() - }) - - it.each([ - { side: 'bottom', align: 'start', left: '100px', top: '324px' }, - { side: 'bottom', align: 'end', left: '180px', top: '324px' }, - { side: 'top', align: 'start', left: '100px', top: '216px' }, - { side: 'top', align: 'end', left: '180px', top: '216px' }, - { side: 'right', align: 'start', left: '304px', top: '300px' }, - { side: 'right', align: 'end', left: '304px', top: '300px' }, - ] as const)('places $side-$align from the matching anchor edges', ({ side, align, left, top }) => { - const ui = show({ open: true, side, align }) - expect(ui.getByTestId('panel').style.left).toBe(left) - expect(ui.getByTestId('panel').style.top).toBe(top) - }) - - it.each([ - { side: 'top', anchorY: 100, fullscreen: false, top: 60 }, - { side: 'top', anchorY: 100, fullscreen: true, top: 20 }, - { side: 'bottom', anchorY: 0, fullscreen: false, top: 14 }, - ] as const)('keeps $side placement at $top with fullscreen=$fullscreen', ({ side, anchorY, fullscreen, top }) => { - const root = document.documentElement - const previous = root.getAttribute('style') - const priorFullscreen = root.getAttribute('data-fullscreen') - try { - root.style.setProperty('--dsh-frame-top-clearance', '40px') - root.toggleAttribute('data-fullscreen', fullscreen) - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, anchorY, 200, 10)) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) - const ui = show({ open: true, side }) - expect(ui.getByTestId('panel').style.top).toBe(`${top}px`) - } finally { - if (previous === null) root.removeAttribute('style') - else root.setAttribute('style', previous) - if (priorFullscreen === null) root.removeAttribute('data-fullscreen') - else root.setAttribute('data-fullscreen', priorFullscreen) - } - }) - - it('keeps the near margins when the panel exceeds the viewport', () => { - vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(1200) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(900) - const ui = show({ open: true }) - expect(ui.getByTestId('panel').style.left).toBe('12px') - expect(ui.getByTestId('panel').style.top).toBe('12px') - }) - - it('follows WebKit visual-viewport offsets, resizing, and scrolling', () => { - const viewport = Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 }) - vi.stubGlobal('visualViewport', viewport) - const remove = vi.spyOn(viewport, 'removeEventListener') - const ui = show({ open: true, side: 'top' }) - const panel = ui.getByTestId('panel') - expect(panel.style.left).toBe('140px') - expect(panel.style.top).toBe('276px') - act(() => { - viewport.offsetLeft = 100 - viewport.offsetTop = 120 - viewport.dispatchEvent(new Event('scroll')) - }) - expect(panel.style.left).toBe('200px') - expect(panel.style.top).toBe('336px') - act(() => { - viewport.height = 200 - viewport.dispatchEvent(new Event('resize')) - }) - expect(panel.style.top).toBe('228px') - ui.rerender() - expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['scroll', 'resize'])) - }) - - it('tracks resizing and ignores observer callbacks after closing', () => { - const records: Array<{ callback: ResizeObserverCallback; observed: Element[]; disconnect: ReturnType }> = [] - vi.stubGlobal('ResizeObserver', class { - private readonly record - constructor(callback: ResizeObserverCallback) { - this.record = { callback, observed: [] as Element[], disconnect: vi.fn() } - records.push(this.record) - } - observe(element: Element) { this.record.observed.push(element) } - disconnect() { this.record.disconnect() } - }) - const ui = show({ open: true, side: 'top' }) - const observer = records[0]! - const panel = ui.getByTestId('panel') - expect(observer.observed).toEqual([ui.getByRole('button'), ui.getByTestId('panel')]) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(160) - act(() => { observer.callback([], {} as ResizeObserver) }) - expect(ui.getByTestId('panel').style.top).toBe('136px') - ui.rerender() - expect(observer.disconnect).toHaveBeenCalledOnce() - const anchor = observer.observed[0]! - const measure = vi.spyOn(anchor, 'getBoundingClientRect') - measure.mockClear() - act(() => { observer.callback([], {} as ResizeObserver) }) - expect(measure).not.toHaveBeenCalled() - expect(panel.style.getPropertyValue('--dsh-anchored-max-width')).toBe('') - expect(panel.style.getPropertyValue('--dsh-anchored-max-height')).toBe('') - }) - - it('positions without ResizeObserver and subscribes only while open', () => { - vi.stubGlobal('ResizeObserver', undefined) - const ui = show({ open: false }) - expect(requestAnimationFrame).not.toHaveBeenCalled() - act(() => { ui.rerender() }) - expect(ui.getByTestId('panel').style.top).toBe('324px') - }) - - it('positions the first render and changed content without a microtask', () => { - const ui = show({ open: true, side: 'top', layoutKey: 'root' }) - const panel = ui.getByTestId('panel') - expect(panel.style.visibility).toBe('') - expect(panel.style.top).toBe('216px') - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(160) - ui.rerender() - expect(panel.style.top).toBe('136px') - }) - - it('uses layout-relative client rects without adding the visual offset twice', () => { - vi.stubGlobal('CSS', { supports: () => false }) - vi.stubGlobal('visualViewport', Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 })) - const ui = show({ open: true, side: 'top' }) - const panel = ui.getByTestId('panel') - expect(panel.style.left).toBe('100px') - expect(panel.style.top).toBe('216px') - expect(panel.style.getPropertyValue('--dsh-anchored-max-width')).toBe('476px') - expect(panel.style.getPropertyValue('--dsh-anchored-max-height')).toBe('376px') - ui.unmount() - expect(panel.style.getPropertyValue('--dsh-anchored-max-width')).toBe('') - expect(panel.style.getPropertyValue('--dsh-anchored-max-height')).toBe('') - }) - - it('tracks a moving host rect, hides a missing anchor, and stops on unmount', () => { - let rect: DOMRect | null = null - const getAnchorRect = vi.fn(() => rect) - const ui = show({ open: true, getAnchorRect, minWidthFromAnchor: true }) - const panel = ui.getByTestId('panel') - expect(panel.style.visibility).toBe('hidden') - rect = new DOMRect(40, 50, 20, 10) - act(() => { frames.shift()!(0) }) - expect(panel.style.left).toBe('40px') - expect(panel.style.top).toBe('64px') - rect.x = 70 - getAnchorRect.mockClear() - act(() => { frames.shift()!(16) }) - expect(getAnchorRect).toHaveBeenCalledOnce() - expect(panel.style.left).toBe('70px') - rect.y = 80 - act(() => { frames.shift()!(32) }) - expect(panel.style.top).toBe('94px') - rect.width = 40 - act(() => { frames.shift()!(48) }) - expect(panel.style.minWidth).toBe('40px') - rect.height = 20 - act(() => { frames.shift()!(64) }) - expect(panel.style.top).toBe('104px') - const measurePanel = vi.spyOn(panel, 'offsetWidth', 'get') - measurePanel.mockClear() - act(() => { frames.shift()!(80) }) - expect(measurePanel).not.toHaveBeenCalled() - rect = null - act(() => { frames.shift()!(96) }) - expect(panel.style.visibility).toBe('hidden') - ui.unmount() - const frame = frames.shift()! - frame(112) - expect(frames).toHaveLength(0) - expect(cancelAnimationFrame).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index bd2c8e8209..eafe886457 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -21,8 +21,8 @@ en: 4005b800e930f581 zh: ccb9318dad7f7e8f /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: - en: f9895e84de5f5eb7 - zh: 844cba2fc3f424bc + en: 3306a6df47a171a5 + zh: 562c0bc60ca10120 /deepseek-ai-dsh-client-ui-schedule/further-exploration: en: 2fd968202916b197 zh: f1ab66194846562b diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 2bb348ba16..dbabecbe0c 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -50,7 +50,7 @@ Time-zone enumeration and localized names use the browser's embedded ICU/CLDR da ## Understand the implementation -Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-hover), including keyboard-highlighted rows. Portaled task menus and date/time pickers use `useAnchoredPosition` to follow their triggers during browser zoom, scrolling, and resizing, while preserving viewport margins and the desktop frame clearance. Task-menu labels and clock values ellipsize when the visual viewport is too narrow. Clock columns shrink within the panel and scroll vertically within its available height; task menus scroll vertically to reach every choice. The Session-header popover follows the same viewport constraints and stays hidden until its first position resolves. +Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-hover), including keyboard-highlighted rows. Portaled task menus, date/time pickers, and the Session-header popover follow their triggers through CSS anchors, with layout-viewport limits and desktop frame clearance. Browser zoom and panning move these panels with the page; they may extend beyond the visual viewport, including while the keyboard is open. Labels ellipsize when they exceed the panel width. Clock columns and task-menu choices scroll vertically within the panel height limit. Horizontal gestures over clock columns can pan the zoomed page; vertical overscroll stays within the column. Outside dismissal and keyboard navigation remain available.
Implementation details — click to expand diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index f7f4ff4d9e..44ae7de94d 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ## 理解实现 -任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。使用 portal 的任务菜单和日期、时间选择器通过 `useAnchoredPosition`,在浏览器缩放、滚动和尺寸变化时跟随触发器,并保留视口边距和桌面标题栏避让。视觉视口过窄时,任务菜单文案和时间值以省略号截断。时间列随面板缩窄,并在可用高度内纵向滚动;任务菜单通过纵向滚动到达每个选项。会话页头弹层遵循同样的视口限制,并在首次定位完成前保持隐藏。 +任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。使用 portal 的任务菜单、日期和时间选择器以及会话页头弹层通过 CSS 锚点跟随触发器,保留布局视口尺寸限制和桌面标题栏避让。浏览器缩放和平移时,这些面板随页面移动,可以超出视觉视口,包括键盘打开时。文案超出面板宽度时以省略号截断。时间列和任务菜单选项在面板高度限制内纵向滚动。时间列上的横向手势可以平移放大后的页面,纵向滚动到边缘时不继续传递到外层。外部关闭和键盘导航保持可用。
实现内部细节 — 点击展开 diff --git a/packages/client/ui-schedule/src/client/ClockPicker.module.css b/packages/client/ui-schedule/src/client/ClockPicker.module.css index 32539a242e..31122d90c5 100644 --- a/packages/client/ui-schedule/src/client/ClockPicker.module.css +++ b/packages/client/ui-schedule/src/client/ClockPicker.module.css @@ -10,10 +10,10 @@ flex-direction: column; min-width: 0; width: 52px; - max-height: min(216px, calc(var(--dsh-anchored-max-height, 222px) - 6px)); + max-height: min(216px, calc(100dvh - 30px)); overflow-x: hidden; overflow-y: auto; - overscroll-behavior: contain; + overscroll-behavior-y: contain; } /* One clock value. The right column's rows are 13px copy, so the columns set diff --git a/packages/client/ui-schedule/src/client/ClockPicker.tsx b/packages/client/ui-schedule/src/client/ClockPicker.tsx index f6bda67861..ede975bed9 100644 --- a/packages/client/ui-schedule/src/client/ClockPicker.tsx +++ b/packages/client/ui-schedule/src/client/ClockPicker.tsx @@ -76,6 +76,8 @@ export interface ClockPickerProps { readonly open: boolean /** The row's trigger button, which anchors the panel. */ readonly anchorRef: RefObject + /** CSS anchor name assigned to the row trigger. */ + readonly anchorName: string /** The staged clock text. */ readonly value: string /** Stage one picked `HH:MM:SS` clock. */ @@ -97,7 +99,7 @@ export interface ClockPickerProps { * @param props - open state, the trigger, the staged clock, the pick and close callbacks, the seconds-column switch, and row copy. * @returns the anchored panel while open, and nothing while closed. */ -export function ClockPicker({ open, anchorRef, value, onPick, onClose, seconds = true, t }: ClockPickerProps): ReactNode { +export function ClockPicker({ open, anchorRef, anchorName, value, onPick, onClose, seconds = true, t }: ClockPickerProps): ReactNode { const columns = seconds ? COLUMNS : COLUMNS.slice(0, 2) const refs = useRef(new Map()) const valueRef = useRef(value) @@ -185,6 +187,7 @@ export function ClockPicker({ open, anchorRef, value, onPick, onClose, seconds = return + /** CSS anchor name assigned to the row trigger. */ + readonly anchorName: string /** The staged ISO date. */ readonly value: string /** Stage one picked ISO date. */ @@ -115,7 +117,7 @@ export interface DatePickerProps { * @param props - open state, the trigger, the staged date, the pick and close callbacks, and row copy. * @returns the anchored panel while open, and nothing while closed. */ -export function DatePicker({ open, anchorRef, value, onPick, onClose, t }: DatePickerProps): ReactNode { +export function DatePicker({ open, anchorRef, anchorName, value, onPick, onClose, t }: DatePickerProps): ReactNode { const refs = useRef(new Map()) const valueRef = useRef(value) valueRef.current = value @@ -174,6 +176,7 @@ export function DatePicker({ open, anchorRef, value, onPick, onClose, t }: DateP return + /** CSS anchor name assigned to the trigger. */ + readonly anchorName: string /** Accessible name of the panel. */ readonly label: string /** Layout class of this picker's contents. @@ -34,16 +33,15 @@ export interface PickerPopoverProps { * @param props - open state, the trigger, the panel name and layout class, the dismissal callback, and the contents. * @returns the panel while open, and nothing while closed. */ -export function PickerPopover({ open, anchorRef, label, className, onClose, children }: PickerPopoverProps): ReactNode { +export function PickerPopover({ open, anchorRef, anchorName, label, className, onClose, children }: PickerPopoverProps): ReactNode { const panelRef = useRef(null) useDismissOnOutsidePointer(anchorRef, open, onClose, panelRef) - const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 }) if (!open) return null return createPortal(
const SECOND_MS = 1_000 -const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Current-Session reminder catalog with durable deletion, rendered only while @@ -56,14 +54,7 @@ function SessionScheduleCatalog({ const rootRef = useRef(null) const triggerRef = useRef(null) const catalogRef = useRef(null) - const catalogPosition = useAnchoredPosition({ - open, - anchorRef: triggerRef, - panelRef: catalogRef, - side: 'bottom', - gap: 5, - margin: 16, - }) + const anchorName = `--dsh-schedule-catalog-${useId().replaceAll(':', '')}` useDismissOnOutsidePointer(rootRef, open, setOpen, catalogRef) @@ -119,6 +110,7 @@ function SessionScheduleCatalog({ const trigger = (
} {draft.frequency === 'once' &&
- - { change({ start: `${date}T${clock}` }); setDateOpen(false) }} onClose={() => { setDateOpen(false) }} t={t} />
} {draft.frequency !== 'advanced' && draft.frequency !== 'minutely' && draft.frequency !== 'hourly' &&
- - { change(draft.frequency === 'daily' ? { dailyTime: time.slice(0, 5) } : { start: `${draft.start.slice(0, 10)}T${time}` }) }} onClose={() => { setClockOpen(false) }} t={t} />
} diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 62f10d6fa7..2c63791d48 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -811,7 +811,7 @@ recent choices stay visible without letting the global list dominate the detail. */ .zoneMenu.zoneMenu { height: min(420px, calc(100dvh - 48px)); - max-height: min(420px, calc(100dvh - 48px), var(--dsh-anchored-max-height, 100dvh)); + max-height: min(420px, calc(100dvh - 48px)); } /* A stronger rule makes the unlabeled recent block read as a distinct section. */ diff --git a/packages/client/ui-schedule/src/client/TaskMenu.module.css b/packages/client/ui-schedule/src/client/TaskMenu.module.css index 2f2c0f6374..f117fa4310 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.module.css +++ b/packages/client/ui-schedule/src/client/TaskMenu.module.css @@ -31,33 +31,42 @@ top: calc(100% + 4px); left: 0; z-index: 100; - min-width: min(144px, var(--dsh-anchored-max-width, 100vw)); - max-width: min(360px, var(--dsh-anchored-max-width, 100vw)); + min-width: min(144px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); } -/* Portal mode: fixed in the viewport, coordinates supplied inline from the - * anchor rect (align resolved in JS, the in-place offset rules above don't - * apply). Portaled lists must layer above modal overlays (z 1000) — an anchor - * inside a dialog still expects its menu on top. */ +/* CSS anchors follow layout changes without constraining pinch zoom. */ .portal { position: fixed; - top: auto; - left: auto; + position-area: none; + position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-end, --dsh-schedule-task-above-end, --dsh-schedule-task-window; + top: max(12px, calc(anchor(bottom) + 4px)); + bottom: 12px; + left: max(12px, anchor(left)); + right: 12px; + margin: 0; + width: max-content; + height: fit-content; + align-self: start; + justify-self: start; z-index: 1100; } -.alignEnd { +.alignEnd:not(.portal) { left: auto; right: 0; } -/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges - * (the hook's margin) and taller content scrolls inside .viewport. The dynamic - * unit, not `vh`, for the same reason as Menu.module.css: mobile browser - * chrome shrinks the visible height below the bars-hidden one `vh` reports. */ +.portal.alignEnd { + left: 12px; + right: max(12px, anchor(right)); + justify-self: end; + position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-start, --dsh-schedule-task-above-start, --dsh-schedule-task-window; +} + +/* Tall lists scroll inside the window's layout bounds. */ .scrollable { - max-height: min(calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh)); - max-height: min(calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh)); + max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); } .viewport { @@ -195,3 +204,48 @@ .separator + .separator { display: none; } + +/* Every candidate reserves the same window insets, including flipped placements. */ +@position-try --dsh-schedule-task-above { + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + bottom: max(12px, calc(anchor(top) + 4px)); + align-self: end; +} + +@position-try --dsh-schedule-task-end { + left: 12px; + right: max(12px, anchor(right)); + justify-self: end; +} + +@position-try --dsh-schedule-task-above-end { + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + bottom: max(12px, calc(anchor(top) + 4px)); + left: 12px; + right: max(12px, anchor(right)); + align-self: end; + justify-self: end; +} + +@position-try --dsh-schedule-task-start { + left: max(12px, anchor(left)); + right: 12px; + justify-self: start; +} + +@position-try --dsh-schedule-task-above-start { + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + bottom: max(12px, calc(anchor(top) + 4px)); + left: max(12px, anchor(left)); + right: 12px; + align-self: end; + justify-self: start; +} + +@position-try --dsh-schedule-task-window { + position-area: none; + inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px; + margin: 0; + align-self: safe end; + justify-self: safe end; +} diff --git a/packages/client/ui-schedule/src/client/TaskMenu.tsx b/packages/client/ui-schedule/src/client/TaskMenu.tsx index 6805ea9852..cb2bd21021 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.tsx +++ b/packages/client/ui-schedule/src/client/TaskMenu.tsx @@ -6,12 +6,12 @@ * the Time zone row's search box, so the four call sites here keep that * capability locally instead of extending the shared card. */ -import { useCallback, useEffect, useRef } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import { useCallback, useEffect, useId, useRef } from 'react' +import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { - IconCheckOutlineRegular, isMobileDevice, useAnchoredPosition, useDismissOnOutsidePointer, + IconCheckOutlineRegular, isMobileDevice, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './TaskMenu.module.css' @@ -61,7 +61,7 @@ export interface TaskMenuProps { onClose: () => void /** List alignment against the anchor (default 'start'). */ align?: 'start' | 'end' - /** Render the list into document.body, fixed-positioned from the anchor rect. */ + /** Render the list into document.body, fixed-positioned by its CSS anchor. */ portal?: boolean /** Owner content pinned above the scrolling rows. */ header?: ReactNode @@ -71,15 +71,6 @@ export interface TaskMenuProps { listClassName?: string | undefined } -/** Unplaced portal list: hidden but laid out at a fixed origin so its offset size is real. */ -const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } - -/** Distance the list keeps from the anchor edge. */ -const ANCHOR_GAP = 4 - -/** Distance the list keeps from each viewport edge. */ -const VIEWPORT_MARGIN = 12 - /** * Whether an entry is a group hairline. * @param entry - one menu entry. @@ -112,16 +103,17 @@ function isLabel(entry: TaskMenuEntry): entry is TaskMenuLabel { * window blur that moved focus into a cross-origin iframe. * @param props.align - list alignment against the anchor (default 'start'). * @param props.portal - render the list into document.body, fixed-positioned - * from the anchor rect (follows scroll and resize while open). + * by its CSS anchor (follows scroll and resize while open). * @param props.header - owner content pinned above the scrolling rows; a control - * it marks `data-menu-field` takes the keyboard on desktop once the list is placed, - * which is the frame a portaled list becomes focusable in. Phones and tablets + * it marks `data-menu-field` takes the keyboard on desktop when opened. Phones and tablets * require explicit field focus. * @param props.className - extra class on the anchor wrapper span. * @param props.listClassName - extra class on the dropdown card itself. * @returns the anchor wrapper with the conditional list. */ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClose, align = 'start', portal = false, header, className, listClassName }: TaskMenuProps) { + const id = useId() + const anchorName = `--dsh-task-menu-${id.replaceAll(':', '')}` const rootRef = useRef(null) const listRef = useRef(null) /** Index the arrow walk last focused, the resume point when focus left the rows. */ @@ -135,9 +127,6 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo closeRef.current = onClose const dismiss = useCallback(() => { closeRef.current() }, []) - const position = useAnchoredPosition({ - open: open && portal, anchorRef: rootRef, panelRef: listRef, align, gap: ANCHOR_GAP, margin: VIEWPORT_MARGIN, - }) useDismissOnOutsidePointer(rootRef, open, dismiss, listRef) /* jscpd:ignore-start -- schedule-local copy of the shared ui-primitives Menu; the two owners change independently. */ @@ -165,12 +154,7 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo } /* jscpd:ignore-end */ - // A portaled list paints hidden until the placement effect measures it, and a - // hidden control refuses focus, so neither a header field's own autoFocus nor - // this effect can take the keyboard in the opening commit. Once the list is - // placed, a header control marked `data-menu-field` takes it. Focus is handed - // over once per open, so a reposition while the reader is on a row does not - // pull the keyboard back. + // Header fields take focus once per open; mobile fields require an explicit tap. useEffect(() => { if (!open) { handedOver.current = false @@ -180,14 +164,11 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo const list = listRef.current /* v8 ignore next -- the list is rendered in the same commit that sets `open`, before this effect runs. */ if (list === null) return - // A portaled list paints hidden until the position hook has measured it, - // and a hidden control refuses focus: the handover waits for that frame. - if (portal && position === null) return const field = list.querySelector('[data-menu-field]') if (field === null) return handedOver.current = true field.focus() - }, [open, portal, position]) + }, [open]) useEffect(() => { if (!open) { @@ -313,14 +294,11 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo ) } - // Portal lists render hidden until placed: the position hook measures this - // pre-render in the same commit, so the first painted frame is already at - // the final coordinates. const list = open && (
+ {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 623fc38a63..b6885853b4 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -268,11 +268,11 @@ describe('ScheduleCatalogAction visibility', () => { }) describe('ScheduleCatalogAction positioning', () => { - it('portals the catalog to the body and left-aligns it when space is available', () => { + it('portals the catalog to the body and binds it to the trigger CSS anchor', () => { const active = [entry('active', START + 60_000), entry('next', START + 120_000)] const view = render() const trigger = screen.getByRole('button', { name: '2 automation tasks' }) - vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + const measure = vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ x: 240, y: 20, left: 240, @@ -289,8 +289,11 @@ describe('ScheduleCatalogAction positioning', () => { const catalog = screen.getByRole('list', { name: en['list.aria'] }) expect(view.container.contains(catalog)).toBe(false) expect(catalog.parentElement).toBe(document.body) - expect(catalog.style.left).toBe('240px') - expect(catalog.style.top).toBe('53px') + expect(trigger.style.anchorName).not.toBe('') + expect(catalog.style.positionAnchor).toBe(trigger.style.anchorName) + expect(catalog.style.left).toBe('') + expect(catalog.style.top).toBe('') + expect(measure).not.toHaveBeenCalled() expect(catalog.style.visibility).toBe('') }) }) diff --git a/packages/client/ui-schedule/tests/task-menu.client.spec.tsx b/packages/client/ui-schedule/tests/task-menu.client.spec.tsx index 9da92427ba..a40ce443d6 100644 --- a/packages/client/ui-schedule/tests/task-menu.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-menu.client.spec.tsx @@ -50,6 +50,22 @@ function OpenMenu({ onSelect, portal = false }: { } describe('TaskMenu', () => { + it('assigns independent CSS anchors to portaled menus without measuring their bounds', () => { + const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + onTestFinished(() => { measure.mockRestore() }) + const view = render(<> + first trigger} items={ITEMS} onSelect={() => {}} onClose={() => {}} /> + second trigger} items={ITEMS} onSelect={() => {}} onClose={() => {}} /> + ) + const anchors = ['first trigger', 'second trigger'].map(label => screen.getByRole('button', { name: label }).parentElement!.style.anchorName) + expect(anchors.every(name => name !== '')).toBe(true) + expect(new Set(anchors).size).toBe(2) + const menus = screen.getAllByRole('menu') + expect(menus.map(menu => menu.style.positionAnchor)).toEqual(anchors) + expect(menus.every(menu => !view.container.contains(menu) && menu.style.left === '' && menu.style.top === '')).toBe(true) + expect(measure).not.toHaveBeenCalled() + }) + it('shows items only while open, and reports the clicked row id', () => { const onSelect = vi.fn() const { rerender } = render( @@ -201,8 +217,7 @@ describe('TaskMenu', () => { const alpha = screen.getByRole('menuitem', { name: 'Alpha' }) expect(document.activeElement).toBe(alpha) - // Placement runs again on scroll and resize, and on the panel's own size - // change; the handover is once per open, so a reader on a row keeps it. + // A viewport resize must not repeat the initial header focus handover. fireEvent(window, new Event('resize')) expect(document.activeElement).toBe(alpha) }) diff --git a/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx b/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx index 5c6b880e91..80c99af5fe 100644 --- a/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx +++ b/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx @@ -17,6 +17,7 @@ beforeEach(() => { // jsdom has no `scrollIntoView`, which the open clock panel calls. Element.prototype.scrollIntoView = vi.fn() anchor = document.createElement('button') + anchor.style.anchorName = '--test-timing-anchor' document.body.append(anchor) }) afterEach(() => { cleanup(); vi.useRealTimers(); anchor.remove() }) @@ -27,10 +28,25 @@ afterEach(() => { cleanup(); vi.useRealTimers(); anchor.remove() }) * @returns the props both pickers take, with spied pick and close callbacks. */ function panel(value: string) { - return { open: true, anchorRef: { current: anchor }, value, onPick: vi.fn(), onClose: vi.fn(), t } + return { open: true, anchorRef: { current: anchor }, anchorName: '--test-timing-anchor', value, onPick: vi.fn(), onClose: vi.fn(), t } } describe('ClockPicker', () => { + it('uses the row CSS anchor and dismisses only outside the trigger and panel', () => { + const props = panel('09:30:00') + render() + const clock = screen.getByRole('dialog', { name: en['timing.time'] }) + expect(clock.parentElement).toBe(document.body) + expect(clock.style.positionAnchor).toBe(anchor.style.anchorName) + expect(clock.style.left).toBe('') + expect(clock.style.top).toBe('') + fireEvent.pointerDown(anchor) + fireEvent.pointerDown(clock) + expect(props.onClose).not.toHaveBeenCalled() + fireEvent.pointerDown(document.body) + expect(props.onClose).toHaveBeenCalledOnce() + }) + it('starts a value without a clock at midnight', () => { const props = panel('') render() diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index f2f07812f8..679e70b4f6 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -27,8 +27,8 @@ en: 595499992ceea693 zh: 83c6147c46a80dca /deepseek-ai-dsh-client-ui-sidebar-documentpreview/office-preview: - en: 62799a1372b966db - zh: e690f9cc467006e6 + en: 9178629b27e9dc87 + zh: 1692e7f9d73a0653 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/navigation: en: 33215200b3e4a8ed zh: 499e4b834f2db95a diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 98b41281c3..dc023a7116 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -109,7 +109,7 @@ Opening an Office file requests conversion on demand. Each read checks renderer Background requests leave the final pending-request and reader slots available for foreground work; setting either limit to one rejects background reads. Renderer replacement restarts generation discovery and authorization once. Another replacement during that retry reports the localized busy message. -A yellow rounded-triangle warning appears before Reload in the document toolbar when the current Office preview has missing fonts. Hover or keyboard focus shows the missing-font count; clicking opens an anchored list that follows browser zoom, Visual Viewport panning, and resizing. Focus enters the list after its first position resolves. Escape, Close, or clicking outside closes the list without hiding the warning or moving the document. Reloading closes the old details; previews with no missing fonts show no warning. Viewed and dismissed states are not retained. +A yellow rounded-triangle warning appears before Reload in the document toolbar when the current Office preview has missing fonts. Hover or keyboard focus shows the missing-font count; clicking opens a CSS-anchored list and moves focus into it. The list follows its trigger within layout-viewport size limits; browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Escape, Close, or clicking outside closes the list without hiding the warning or moving the document. Reloading closes the old details; previews with no missing fonts show no warning. Viewed and dismissed states are not retained.
Office implementation — click to expand diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 87d2f66355..a274c720da 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -109,7 +109,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本;CSV 还提供 后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。 -当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开锚定字体列表,列表跟随浏览器缩放、Visual Viewport 平移及尺寸变化,并在首次定位完成后接收焦点。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。 +当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开 CSS 锚定的字体列表,并将焦点移入其中。列表跟随触发器,并按布局视口限制尺寸;浏览器缩放和平移时列表随页面移动,因此可以超出视觉视口。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。
Office 实现——点击展开 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css index 419f96773c..93adbe7a9e 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -30,7 +30,7 @@ z-index: 100; box-sizing: border-box; width: min(340px, calc(100vw - 24px)); - max-height: min(calc(100dvh - 24px), var(--dsh-anchored-max-height, 100vh)); + max-height: min(calc(100dvh - 24px)); padding: 16px; overflow: auto; color: var(--dsw-alias-label-primary); @@ -40,7 +40,7 @@ box-shadow: var(--dsw-elevation-prominent); font-size: 13px; line-height: 1.6; - max-width: var(--dsh-anchored-max-width, none); + max-width: calc(100vw - 24px); } .panelHeader { @@ -78,3 +78,33 @@ padding: 8px 0; border-top: 0.5px solid var(--dsw-alias-border-l2); } + +/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ +.panel { + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-fonts-window, --dsh-fonts-viewport; + align-self: start; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 8px 0; +} + +@position-try --dsh-fonts-window { + position-area: none; + top: max(12px, calc(anchor(bottom) + 8px)); + bottom: 12px; + left: 12px; + right: 12px; + margin: 0; + align-self: safe start; + justify-self: safe start; +} + +@position-try --dsh-fonts-viewport { + position-area: none; + inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px; + margin: 0; + align-self: safe start; + justify-self: safe start; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx index 061d639777..84a5e36d57 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx @@ -3,7 +3,7 @@ import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import { Button, IconCloseOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconCloseOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' import common from '../TextPreview.module.css' import css from './FontNotice.module.css' @@ -23,22 +23,21 @@ export function FontNotice({ fonts, t }: FontNoticeProps): ReactNode { const anchor = useRef(null) const panel = useRef(null) const id = useId() - const position = useAnchoredPosition({ open, anchorRef: anchor, panelRef: panel, gap: 8, margin: 12 }) + const anchorName = `--dsh-font-notice-${id.replaceAll(':', '')}` useDismissOnOutsidePointer(anchor, open, () => { setExpanded(false) }, panel) const closeDetails = (): void => { setExpanded(false) anchor.current?.querySelector('button')?.focus() } - const positioned = position !== null useLayoutEffect(() => { - if (!open || !positioned) return + if (!open) return panel.current?.focus() - }, [open, positioned]) + }, [open]) if (fonts.length === 0) return null const label = t('viewMissingFonts', { count: fonts.length }) return <> - + {open && createPortal( { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true) } }} diff --git a/packages/experimental/session-inspector/src/client/views/inspector.module.css b/packages/experimental/session-inspector/src/client/views/inspector.module.css index 4b333fd24d..ec25bed1a5 100644 --- a/packages/experimental/session-inspector/src/client/views/inspector.module.css +++ b/packages/experimental/session-inspector/src/client/views/inspector.module.css @@ -43,7 +43,7 @@ @media (hover: hover) { .typeFilterButton:hover { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); } } -.typeFilterPanel { position: fixed; z-index: 1250; width: 290px; max-width: min(calc(100vw - 24px), var(--dsh-anchored-max-width, 100vw)); max-height: min(calc(100vh - 24px), var(--dsh-anchored-max-height, 100vh)); overflow: auto; box-sizing: border-box; padding: 10px; color: var(--dsw-alias-label-primary); font-size: 11px; } +.typeFilterPanel { position: fixed; z-index: 1250; width: 290px; max-width: min(calc(100vw - 24px)); max-height: min(calc(100vh - 24px)); overflow: auto; box-sizing: border-box; padding: 10px; color: var(--dsw-alias-label-primary); font-size: 11px; } .typeFilterInput { width: 100%; box-sizing: border-box; padding: 5px 6px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 4px; background: transparent; color: inherit; font: inherit; } .typeFilterHelp { margin: 6px 0; color: var(--dsw-alias-label-secondary); } .typeFilterOptions { max-height: 180px; overflow: auto; } @@ -112,3 +112,33 @@ @media (prefers-reduced-motion: reduce) { .table tr { animation: none; } } + +/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ +.typeFilterPanel { + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-types-window, --dsh-types-viewport; + align-self: start; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 4px 0; +} + +@position-try --dsh-types-window { + position-area: none; + top: max(12px, calc(anchor(bottom) + 4px)); + bottom: 12px; + left: 12px; + right: 12px; + margin: 0; + align-self: safe start; + justify-self: safe start; +} + +@position-try --dsh-types-viewport { + position-area: none; + inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px; + margin: 0; + align-self: safe start; + justify-self: safe start; +} From 33566bb43e732a6f0145429eaa32a22373ebdad9 Mon Sep 17 00:00:00 2001 From: Stevring Date: Mon, 2 Nov 2026 15:57:55 +0800 Subject: [PATCH 3/6] test(client): update CSS menu and overlay margin coverage --- .../tests/overlay-top-margin.client.spec.ts | 60 +++++++++++++++++++ .../tests/worktree-toggle.client.spec.tsx | 9 +-- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts diff --git a/packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts b/packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts new file mode 100644 index 0000000000..b77368bcc3 --- /dev/null +++ b/packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +/** Overlay clearance follows the desktop frame and visible anchor position. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { overlayTopMargin } from '../src/overlay-top-margin.ts' + +const root = document.documentElement +let originalClearance: string +let originalPriority: string +let originalFullscreen: string | null + +beforeEach(() => { + originalClearance = root.style.getPropertyValue('--dsh-frame-top-clearance') + originalPriority = root.style.getPropertyPriority('--dsh-frame-top-clearance') + originalFullscreen = root.getAttribute('data-fullscreen') + root.style.removeProperty('--dsh-frame-top-clearance') + root.removeAttribute('data-fullscreen') +}) + +afterEach(() => { + root.style.setProperty('--dsh-frame-top-clearance', originalClearance, originalPriority) + if (originalFullscreen === null) root.removeAttribute('data-fullscreen') + else root.setAttribute('data-fullscreen', originalFullscreen) +}) + +describe('overlayTopMargin', () => { + it('uses the requested margin when the frame publishes no clearance', () => { + expect(overlayTopMargin(12)).toBe(12) + }) + + it('uses the frame margin when no anchor is provided', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12)).toBe(60) + }) + + it('caps clearance at a visible anchor and its requested gap', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12, { top: 10, bottom: 24 }, 4)).toBe(28) + }) + + it('does not cap clearance at an anchor above the viewport', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12, { top: -10, bottom: 24 }, 4)).toBe(60) + }) + + it('keeps the requested minimum when the anchor cap is smaller', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12, { top: 0, bottom: 4 }, 4)).toBe(12) + }) + + it('keeps the requested minimum when it exceeds frame clearance', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(80)).toBe(80) + }) + + it('excludes native frame clearance in fullscreen', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + root.setAttribute('data-fullscreen', '') + expect(overlayTopMargin(12)).toBe(20) + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx b/packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx index 3aacb86aeb..baec5b9cb9 100644 --- a/packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx @@ -251,11 +251,12 @@ describe('WorktreeToggle base branch menu', () => { expect(anchor.compareDocumentPosition(b.checkbox()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() const wrapper = anchor.parentElement if (wrapper === null) throw new Error('no menu wrapper') - vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 40, 120, 28)) - const menu = await openMenu(b) - expect(menu.style.left).toBe('220px') - expect(menu.style.top).toBe('72px') + expect(anchor.getAttribute('aria-expanded')).toBe('true') + expect(menu.dataset.anchorSide).toBe('bottom') + expect(menu.dataset.anchorAlign).toBe('end') + expect(menu.style.positionAnchor).not.toBe('') + expect(menu.style.positionAnchor).toBe(wrapper.style.anchorName) }) it('lists the local branches, marks the current and the chosen one, and requests another base', async () => { From 953500de9fcf121556195ceef77c86f879537071 Mon Sep 17 00:00:00 2001 From: Stevring Date: Mon, 2 Nov 2026 16:03:47 +0800 Subject: [PATCH 4/6] fix(client): preserve layout margins in anchored menu candidates --- apps/web/tests/menu-position.e2e.ts | 64 +++++++++++++++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/Menu.module.css | 62 ++++++++++++------ .../tests/menu-top-clearance.client.spec.tsx | 31 --------- 6 files changed, 109 insertions(+), 56 deletions(-) create mode 100644 apps/web/tests/menu-position.e2e.ts delete mode 100644 packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx diff --git a/apps/web/tests/menu-position.e2e.ts b/apps/web/tests/menu-position.e2e.ts new file mode 100644 index 0000000000..6105de905a --- /dev/null +++ b/apps/web/tests/menu-position.e2e.ts @@ -0,0 +1,64 @@ +/** CSS menu candidates preserve layout-window margins through anchor alignment and flipping. */ +import { readFile } from 'node:fs/promises' +import { chromium, webkit, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' + +const cases = [ + { name: 'end alignment near the left edge', side: 'bottom', align: 'end', x: 232, y: 100, width: 255, height: 100, left: 12, top: 132, clearance: 12 }, + { name: 'start alignment near the right edge', side: 'bottom', align: 'start', x: 570, y: 100, width: 255, height: 100, left: 333, top: 132, clearance: 12 }, + { name: 'above placement near the desktop titlebar', side: 'top', align: 'start', x: 120, y: 110, width: 160, height: 80, left: 120, top: 142, clearance: 60 }, + { name: 'below placement near the bottom edge', side: 'bottom', align: 'start', x: 120, y: 330, width: 160, height: 100, left: 120, top: 226, clearance: 12 }, + { name: 'right placement near the right edge', side: 'right', align: 'start', x: 540, y: 100, width: 160, height: 100, left: 376, top: 100, clearance: 12 }, + { name: 'window fallback when neither vertical side fits', side: 'bottom', align: 'start', x: 120, y: 180, width: 300, height: 320, left: 12, top: 60, clearance: 60 }, +] as const + +type Placement = (typeof cases)[number] + +async function showMenu(page: Page, css: string, placement: Placement): Promise { + await page.setContent(` + + `) +} + +describe.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])('menu layout margins ($name)', ({ engine }) => { + let browser: Browser | undefined + let css: string + beforeAll(async () => { + css = await readFile(new URL('../../../packages/client/ui-primitives/src/Menu.module.css', import.meta.url), 'utf8') + browser = await engine.launch() + }) + afterAll(async () => { await browser?.close() }) + + it.each(cases)('$name', async (placement) => { + const page = await browser!.newPage({ viewport: { width: 600, height: 400 } }) + onTestFinished(() => page.close()) + await showMenu(page, css, placement) + await expect.poll(async () => { + const rect = await page.getByRole('menu').boundingBox() + if (rect === null) return null + return { + left: Math.round(rect.x), top: Math.round(rect.y), + bounded: rect.x >= 12 && rect.x + rect.width <= 588 + && rect.y >= placement.clearance && rect.y + rect.height <= 388, + } + }).toEqual({ left: placement.left, top: placement.top, bounded: true }) + }) + + it.runIf(engine === chromium)('keeps layout bounds while zoom allows visual-viewport overflow', async () => { + const page = await browser!.newPage({ viewport: { width: 600, height: 400 } }) + onTestFinished(() => page.close()) + await showMenu(page, css, cases[0]) + const menu = page.getByRole('menu') + const before = await menu.boundingBox() + const cdp = await page.context().newCDPSession(page) + try { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) + await expect.poll(() => page.evaluate(() => visualViewport!.scale)).toBe(3) + await expect.poll(() => menu.boundingBox()).toEqual(before) + expect(await menu.evaluate(node => node.getBoundingClientRect().width > visualViewport!.width)).toBe(true) + } finally { + await cdp.detach() + } + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 531a965096..b94032311c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: f83e4ddce01bee87 zh: b9326aed87a4f211 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 3b6a709dbe3c81ca - zh: fb8b38995dbcb73d + en: 8aa561d0c21f177f + zh: 8c1924a20f78f3ff /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 3271871a42bd52c8 zh: 89b7a9f03018454d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 2d8f2fc2f3..d0bb068af8 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -96,7 +96,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A submenu-free portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region. Anchored overlays add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. When space is insufficient, CSS tries flipped placements before a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A submenu-free portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region. `HoverCard` adds 20px to the frame's published top clearance, or keeps 20px in native fullscreen, while preserving its own minimum margin in ordinary browsers; while an anchor is inside that top strip, the card is never pushed lower than the anchor's bottom edge plus its gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. Every placement candidate reserves 12px layout-viewport margins and uses the frame overlay inset as its minimum top edge; pointer-positioned contextual menus keep 12px at the top. When the preferred placement cannot fit, CSS tries a horizontal adjustment or the opposite side before using a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 5565bb7c27..258eb3e481 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -96,7 +96,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。无子菜单的 portal 模式 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外。锚定浮层 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,其浮层(包括 `HoverCard`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。空间不足时,CSS 尝试翻转方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。无子菜单的 portal 模式 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外。`HoverCard` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,卡片不会被推到低于锚点底边加自身间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。每个候选位置都保留 12px 布局视口边距,并将框架浮层起点作为顶部下限;按指针定位的上下文菜单顶部保留 12px。首选位置放不下时,CSS 尝试横向调整或相反方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 8edbf4b3c5..2aca0337a9 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -39,61 +39,81 @@ display: none; } -/* Fixed cards follow their CSS anchor while the browser handles page zoom. */ +/* Every candidate reserves layout-window margins while browser zoom moves the page. */ .portal { --dsh-menu-window-top: max(12px, var(--dsh-frame-overlay-top, 12px)); position: fixed; - inset: auto; - position-area: bottom span-right; + position-area: none; + top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px)); + bottom: 12px; + left: max(12px, anchor(left)); + right: 12px; justify-self: start; align-self: start; - margin: 4px 0 0; - position-try-fallbacks: --dsh-menu-wide-below, flip-block, --dsh-menu-wide-above, flip-inline, flip-block flip-inline, --dsh-menu-window; + margin: 0; + position-try-fallbacks: --dsh-menu-wide-below, --dsh-menu-above, --dsh-menu-wide-above, --dsh-menu-window; width: max-content; height: fit-content; z-index: 1100; } .portal[data-anchor-side='top'] { - position-area: top span-right; + top: var(--dsh-menu-window-top); + bottom: max(12px, calc(anchor(top) + 4px)); align-self: end; - margin: 0 0 4px; - position-try-fallbacks: --dsh-menu-wide-above, flip-block, --dsh-menu-wide-below, flip-inline, flip-block flip-inline, --dsh-menu-window; + position-try-fallbacks: --dsh-menu-wide-above, --dsh-menu-below, --dsh-menu-wide-below, --dsh-menu-window; +} +.portal[data-anchor-side='right'] { + top: max(var(--dsh-menu-window-top), anchor(top)); + bottom: 12px; + left: max(12px, calc(anchor(right) + 4px)); + right: 12px; + position-try-fallbacks: --dsh-menu-left, --dsh-menu-wide-below, --dsh-menu-window; } -.portal[data-anchor-side='right'] { position-area: right span-bottom; margin: 0 0 0 4px; } .portal[data-anchor-align='end']:not([data-anchor-side='right']) { - position-area: bottom span-left; + left: 12px; + right: max(12px, anchor(right)); justify-self: end; --dsh-menu-wide-align: start; } -.portal[data-anchor-side='top'][data-anchor-align='end'] { position-area: top span-left; } .portal[data-match-anchor-width] { min-width: min(anchor-size(width), calc(100vw - 24px)); } .portal[data-context-menu] { --dsh-menu-window-top: 12px; } +@position-try --dsh-menu-below { + top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px)); + bottom: 12px; + align-self: start; +} +@position-try --dsh-menu-above { + top: var(--dsh-menu-window-top); + bottom: max(12px, calc(anchor(top) + 4px)); + align-self: end; +} @position-try --dsh-menu-wide-below { - position-area: none; - top: calc(anchor(bottom) + 4px); + top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px)); + bottom: 12px; left: 12px; right: 12px; - bottom: auto; - margin: 0; + align-self: start; justify-self: safe var(--dsh-menu-wide-align, end); } @position-try --dsh-menu-wide-above { - position-area: none; - top: auto; + top: var(--dsh-menu-window-top); + bottom: max(12px, calc(anchor(top) + 4px)); left: 12px; right: 12px; - bottom: calc(anchor(top) + 4px); - margin: 0; + align-self: end; justify-self: safe var(--dsh-menu-wide-align, end); } +@position-try --dsh-menu-left { + left: 12px; + right: max(12px, calc(anchor(left) + 4px)); + justify-self: end; +} @position-try --dsh-menu-window { - position-area: none; top: var(--dsh-menu-window-top); left: 12px; right: 12px; bottom: 12px; - margin: 0; align-self: safe start; justify-self: safe start; } diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx deleted file mode 100644 index 135a55540e..0000000000 --- a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** The window fallback reserves desktop chrome while direct anchor placement keeps its normal gap. */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/Menu.module.css', import.meta.url), 'utf8') - -describe('Menu top clearance', () => { - it('uses the frame overlay inset when no anchor-side placement fits', () => { - const fallback = /@position-try --dsh-menu-window\s*\{([^}]+)\}/.exec(css)?.[1] - expect(fallback).toBeDefined() - expect(fallback).toMatch(/top:\s*var\(--dsh-menu-window-top\)/) - expect(css).toMatch(/\.portal\s*\{[^}]*--dsh-menu-window-top:\s*max\(12px, var\(--dsh-frame-overlay-top, 12px\)\)/) - expect(fallback).toMatch(/bottom:\s*12px/) - }) - - it('starts direct placement at the anchor with the normal four-pixel gap', () => { - const portal = /\.portal\s*\{([^}]+)\}/.exec(css)?.[1] - expect(portal).toBeDefined() - expect(portal).toMatch(/position-area:\s*bottom span-right/) - expect(portal).toMatch(/margin:\s*4px 0 0/) - expect(portal).toMatch(/position-try-fallbacks:[^;]*flip-block[^;]*--dsh-menu-window/) - }) - - it('keeps pointer-menu height independent of desktop frame clearance', () => { - const contextual = /\.list\[data-context-menu\]\.scrollable\s*\{([^}]+)\}/.exec(css)?.[1] - expect(contextual).toBeDefined() - expect(contextual).toContain('calc(100dvh - 24px)') - expect(contextual).not.toContain('--dsh-frame-overlay-top') - expect(css).toMatch(/\.portal\[data-context-menu\]\s*\{\s*--dsh-menu-window-top:\s*12px;/) - }) -}) From 0cb15241c0730a5f6ac0467d5bb5b6141cbaa5d6 Mon Sep 17 00:00:00 2001 From: Stevring Date: Mon, 2 Nov 2026 19:52:32 +0800 Subject: [PATCH 5/6] fix(client): preserve CSS menu lifecycle and mobile bounds --- .../src/client/chat/stat-dialog.module.css | 20 +++--- .../client/skeleton/ContextMeter.module.css | 20 +++--- .../src/client/JobListAction.module.css | 20 +++--- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PluginManagerPage.module.css | 25 +++---- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Menu.tsx | 25 ++++--- .../tests/context-menu.client.spec.tsx | 4 +- .../menu-anchor-visibility.client.spec.tsx | 65 ++++++++++++++++++- .../src/client/office/FontNotice.module.css | 20 +++--- .../client/SubagentHeaderLineage.module.css | 29 ++++----- .../src/client/WorkspacePicker.tsx | 1 + .../src/client/views/inspector.module.css | 31 +++++---- 15 files changed, 160 insertions(+), 110 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css index 7d11b2702b..de25ad89fe 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css @@ -21,11 +21,18 @@ line-height: 18px; color: var(--dsw-alias-label-secondary); cursor: default; - max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); + max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); overflow-x: hidden; overflow-y: auto; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + position-area: top span-right; + position-try-fallbacks: --dsh-stat-window, flip-block, --dsh-stat-viewport; + align-self: end; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 8px 0; } /* Dialog heading row: the section name left, the section's headline value @@ -92,17 +99,6 @@ white-space: nowrap; } -/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ -.panel { - position-area: top span-right; - position-try-fallbacks: --dsh-stat-window, flip-block, --dsh-stat-viewport; - align-self: end; - justify-self: start; - height: fit-content; - min-height: 0; - margin: 8px 0; -} - @position-try --dsh-stat-window { position-area: none; top: max(12px, var(--dsh-frame-overlay-top, 12px)); diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css index b0edfbc5bc..b669e90369 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -66,11 +66,18 @@ color: var(--dsw-alias-label-secondary); cursor: default; max-width: calc(100vw - 24px); - max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); + max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); overflow-x: hidden; overflow-y: auto; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + position-area: top span-right; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-context-window, --dsh-context-viewport; + align-self: end; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 8px 0; } .header { @@ -165,17 +172,6 @@ color: var(--dsw-alias-label-primary); } -/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ -.panel { - position-area: top span-right; - position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-context-window, --dsh-context-viewport; - align-self: end; - justify-self: start; - height: fit-content; - min-height: 0; - margin: 8px 0; -} - @position-try --dsh-context-window { position-area: none; top: max(12px, var(--dsh-frame-overlay-top, 12px)); diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index b33707ee3d..1f44bb7eb8 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -89,12 +89,19 @@ flex-direction: column; width: 500px; max-width: min(560px, calc(100vw - 32px)); - max-height: min(480px, calc(100vh - 140px)); + max-height: min(480px, calc(100dvh - 140px)); overflow: hidden; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-jobs-window, --dsh-jobs-viewport; + align-self: start; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 5px 0; } .menuBody { @@ -445,17 +452,6 @@ color: var(--dsw-alias-state-error-primary); } -/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ -.menu { - position-area: bottom span-right; - position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-jobs-window, --dsh-jobs-viewport; - align-self: start; - justify-self: start; - height: fit-content; - min-height: 0; - margin: 5px 0; -} - @position-try --dsh-jobs-window { position-area: none; top: max(12px, calc(anchor(bottom) + 5px)); 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 b75aca1bdf..072e3e13c4 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -110,7 +110,7 @@ height: fit-content; min-width: min(240px, calc(100vw - 24px)); max-width: min(420px, calc(100vw - 24px)); - max-height: min(360px, calc(100vh - 96px)); + max-height: min(360px, calc(100dvh - 96px)); overflow: hidden; padding: 4px; /* Surface tokens match the Menu primitive card (ui-primitives diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index d247ad5eaa..0e7e89f3d1 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -681,7 +681,6 @@ outline-offset: 1px; } - .reason { margin: 0; font-size: 12px; @@ -970,7 +969,7 @@ gap: 4px; box-sizing: border-box; width: min(440px, calc(100vw - 24px)); - margin: 0; + padding: 8px; border: 0; /* An opaque layer keeps the registry form controls readable over the dialog. */ @@ -980,11 +979,18 @@ box-shadow: var(--dsw-elevation-prominent); min-width: 0; max-width: calc(100vw - 24px); - max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); + max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); overflow-x: hidden; overflow-y: auto; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + position-area: bottom span-left; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-registry-window, --dsh-registry-viewport; + align-self: start; + justify-self: end; + height: fit-content; + min-height: 0; + margin: 6px 0; } .registryOption { @@ -1688,8 +1694,6 @@ label.registryOption { color: var(--dsw-alias-state-error-primary); } - - .rowFailure { margin: 4px 0 0 56px; font-size: 12px; @@ -2249,17 +2253,6 @@ button.versionSummary:focus-visible { } } -/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ -.registry { - position-area: bottom span-left; - position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-registry-window, --dsh-registry-viewport; - align-self: start; - justify-self: end; - height: fit-content; - min-height: 0; - margin: 6px 0; -} - @position-try --dsh-registry-window { position-area: none; top: max(12px, calc(anchor(bottom) + 6px)); diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b94032311c..16572034b1 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -27,8 +27,8 @@ en: 3271871a42bd52c8 zh: 89b7a9f03018454d /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: - en: 18c48eb92b227f6d - zh: 186c0923240e9a82 + en: 3fd7072b662eed0a + zh: 88c6c299c0a6f8cd /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: en: 6fbd9df222b41e09 zh: 3bdb4a6b9b6bff9e diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d0bb068af8..b258815a14 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -132,7 +132,7 @@ Explicit `./`, `../`, slash/backslash-rooted paths (including UNC), and Windows The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, and `ZoomControls` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. -`Menu.anchorRef` names a trigger rendered outside the component; `anchorPoint` accepts client-viewport coordinates, matching `event.clientX`/`clientY` and `getBoundingClientRect()`. On Safari, opening the menu or changing the point converts these coordinates to the layout viewport once. CSS then handles zoom and panning without continuous viewport listeners or rectangle measurements. `Menu.contextual` positions from that point without the frame inset and closes on a right-click inside the popup. Contextual popups with no rendered `role="menuitem"` rows stay hidden, including when legacy Slots return no content. Structured `items` reserve an icon column per divider group; `footer` and each submenu calculate their own groups. A contextual `footer` with a leading separator uses that line instead of adding a second border. Opaque `children` retain their own layout; a legacy Slot after data rows keeps its declared leading separator. `exiting` retains an inert popup for its fade; `returnFocus` restores the original control after asynchronous preparation. `MenuSurface.phase` fades the material, direct content, and macOS backing separately so the material keeps blurring the page. `MENU_TRANSITION_MS` supplies both CSS timing and the popup lifecycle timers. Ordinary dropdowns retain their existing timing. +`Menu.anchorRef` names a trigger rendered outside the component, including one attached later in the same commit or replaced between openings. Keyboard dismissal of an `autoFocus` menu restores the previously focused control while it remains connected. `anchorPoint` accepts client-viewport coordinates, matching `event.clientX`/`clientY` and `getBoundingClientRect()`. On Safari, opening the menu or changing the point converts these coordinates to the layout viewport once. CSS then handles zoom and panning without continuous viewport listeners or rectangle measurements. `Menu.contextual` positions from that point without the frame inset and closes on a right-click inside the popup. Contextual popups with no rendered `role="menuitem"` rows stay hidden, including when legacy Slots return no content. Structured `items` reserve an icon column per divider group; `footer` and each submenu calculate their own groups. A contextual `footer` with a leading separator uses that line instead of adding a second border. Opaque `children` retain their own layout; a legacy Slot after data rows keeps its declared leading separator. `exiting` retains an inert popup for its fade; `returnFocus` restores the original control after asynchronous preparation. `MenuSurface.phase` fades the material, direct content, and macOS backing separately so the material keeps blurring the page. `MENU_TRANSITION_MS` supplies both CSS timing and the popup lifecycle timers. Ordinary dropdowns open without the contextual preparation delay. ----- diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 258eb3e481..3b2bec5f17 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -132,7 +132,7 @@ kind: "package-library" 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 与 `ZoomControls` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 -`Menu` 的 `anchorRef` 指定组件外部的触发元素;`anchorPoint` 接收客户端视口坐标,与 `event.clientX`/`clientY` 和 `getBoundingClientRect()` 一致。Safari 在菜单打开或坐标变化时将其转换为布局视口坐标一次,后续缩放和平移由 CSS 处理,不持续监听视口或测量矩形。`Menu.contextual` 从该指针位置定位而不避让顶部栏,并在弹窗内右键时关闭。右键弹窗没有实际渲染的 `role="menuitem"` 行时保持隐藏,包括旧 Slot 返回空内容的情况。结构化 `items` 按分隔线组预留图标栏;`footer` 与各子菜单独立计算分组。右键菜单的 `footer` 以分隔线开头时,直接使用该线,不再叠加边框。不透明的 `children` 保留原有布局;数据行后方的旧 Slot 保留其声明的首条分隔线。`exiting` 在淡出期间保留不可交互的弹窗;`returnFocus` 在异步准备后恢复原控件焦点。`MenuSurface.phase` 分别对材质层、直接内容和 macOS 背景淡入淡出,让材质层继续模糊页面。`MENU_TRANSITION_MS` 同时提供 CSS 动画与弹窗生命周期计时。普通下拉菜单保留原有时序。 +`Menu` 的 `anchorRef` 指定组件外部的触发元素,支持同一次提交中稍后挂载的元素,以及在两次打开之间替换的元素。`autoFocus` 菜单通过键盘关闭时,会恢复到打开前仍在页面中的焦点控件。`anchorPoint` 接收客户端视口坐标,与 `event.clientX`/`clientY` 和 `getBoundingClientRect()` 一致。Safari 在菜单打开或坐标变化时将其转换为布局视口坐标一次,后续缩放和平移由 CSS 处理,不持续监听视口或测量矩形。`Menu.contextual` 从该指针位置定位而不避让顶部栏,并在弹窗内右键时关闭。右键弹窗没有实际渲染的 `role="menuitem"` 行时保持隐藏,包括旧 Slot 返回空内容的情况。结构化 `items` 按分隔线组预留图标栏;`footer` 与各子菜单独立计算分组。右键菜单的 `footer` 以分隔线开头时,直接使用该线,不再叠加边框。不透明的 `children` 保留原有布局;数据行后方的旧 Slot 保留其声明的首条分隔线。`exiting` 在淡出期间保留不可交互的弹窗;`returnFocus` 在异步准备后恢复原控件焦点。`MenuSurface.phase` 分别对材质层、直接内容和 macOS 背景淡入淡出,让材质层继续模糊页面。`MENU_TRANSITION_MS` 同时提供 CSS 动画与弹窗生命周期计时。普通下拉菜单不经过右键菜单的准备延迟。 ----- diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 8b6f95bc34..d433e02be2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -276,8 +276,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI */ const refocusAnchor = (navigation = false): void => { const remembered = triggerRef.current - const trigger = returnFocus?.() - ?? (anchorRef === undefined || anchorRef.current?.contains(remembered) === true ? remembered : anchorRef.current) + const trigger = returnFocus?.() ?? (remembered?.isConnected ? remembered : anchorRef?.current) ?? null const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled ? trigger : rootRef.current?.querySelector('button:not(:disabled)') if (target == null) return @@ -310,8 +309,13 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI const anchorName = `--dsh-menu-trigger-${menuId.replaceAll(':', '')}` const [externalAnchor, setExternalAnchor] = useState(null) const positioned = !portal || anchorRef === undefined || externalAnchor !== null - useLayoutEffect(() => { - if (!open || !portal || anchorRef === undefined) return + // External refs can attach after this component or replace their element + // without changing ref identity. Publish the anchor after all refs commit. + useEffect(() => { + if (!open || !portal || anchorRef === undefined) { + setExternalAnchor(null) + return + } const element = anchorRef.current setExternalAnchor(element) if (element === null) return @@ -322,11 +326,13 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI }) useEffect(() => { if (!open || !portal || exiting !== undefined) return - const element = returnFocus?.() ?? (anchorRef === undefined ? rootRef.current : externalAnchor) - if (element == null) return + const currentAnchor = (): HTMLElement | null => returnFocus?.() ?? (anchorRef === undefined ? rootRef.current : anchorRef.current) + const element = currentAnchor() + if (element === null) return let dismissed = false const check = (): void => { - if (dismissed || (element.isConnected && element.closest('[inert], [hidden]') === null)) return + const current = currentAnchor() + if (dismissed || (current !== null && current.isConnected && current.closest('[inert], [hidden]') === null)) return dismissed = true close.current() } @@ -346,8 +352,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI useEffect(() => { if (!open) return const active = document.activeElement - triggerRef.current = active instanceof HTMLElement - && (anchorRef?.current?.contains(active) === true || anchorPoint !== undefined || rootRef.current?.contains(active) === true) + triggerRef.current = active instanceof HTMLElement && active !== document.body + && (anchorRef !== undefined || anchorPoint !== undefined || rootRef.current?.contains(active) === true) ? active : null }, [open]) @@ -618,7 +624,6 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI ) const menu = view.getByRole('menu') - const point = view.container.querySelector('[data-menu-point-anchor]')! + const point = view.container.firstElementChild as HTMLElement expect(point.style.left).toBe('200px') expect(point.style.top).toBe('20px') expect(menu.style.positionAnchor).toBe(point.style.anchorName) @@ -33,7 +33,7 @@ it.each([false, true])('captures the point in layout coordinates once with visua const props = { portal: true, contextual: true, anchor: null, onClose: vi.fn(), items: [{ id: 'copy', label: 'Copy' }] } const view = render() - const point = (): HTMLElement => view.container.querySelector('[data-menu-point-anchor]')! + const point = (): HTMLElement => view.container.firstElementChild as HTMLElement expect(point().style.left).toBe(visualOrigin ? '240px' : '200px') expect(point().style.top).toBe(visualOrigin ? '160px' : '100px') viewport.offsetLeft = 80 diff --git a/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx b/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx index 19d5f844e0..df7997086c 100644 --- a/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** A portalled menu follows its anchor's interactive lifetime. */ import { useRef, useState } from 'react' -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { Menu } from '../src/Menu.tsx' @@ -106,3 +106,66 @@ it.each([undefined, 'existing-panel'])('restores the trigger aria-controls value view.rerender() expect(trigger.getAttribute('aria-controls')).toBe(previous ?? null) }) + + +it('reopens after its external anchor is replaced while closed', () => { + function Fixture() { + const [open, setOpen] = useState(false) + const [generation, setGeneration] = useState(0) + const anchor = useRef(null) + return <> + + + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + expect(screen.getByRole('menuitem', { name: 'Copy' })).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + const previous = screen.getByRole('button', { name: 'Open' }) + fireEvent.click(screen.getByRole('button', { name: 'Replace anchor' })) + expect(previous.isConnected).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + expect(screen.getByRole('menuitem', { name: 'Copy' })).toBeTruthy() +}) + +it.each(['before', 'after'])('opens with an external anchor mounted %s the menu in the same commit', (order) => { + function Fixture() { + const [open, setOpen] = useState(false) + const anchor = useRef(null) + const trigger = open && + return <> + + {order === 'before' && trigger} + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + {order === 'after' && trigger} + + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + expect(screen.getByRole('menuitem', { name: 'Copy' })).toBeTruthy() +}) + +it('returns focus to the input that opened an external-anchor menu', () => { + function Fixture() { + const [open, setOpen] = useState(false) + const anchor = useRef(null) + return <> + { if (event.key === 'F2') setOpen(true) }} /> + + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + + } + render() + const input = screen.getByRole('textbox', { name: 'Draft' }) + act(() => { input.focus() }) + fireEvent.keyDown(input, { key: 'F2' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Copy' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(document.activeElement).toBe(input) +}) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css index 93adbe7a9e..2ef0ab0b67 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -30,7 +30,7 @@ z-index: 100; box-sizing: border-box; width: min(340px, calc(100vw - 24px)); - max-height: min(calc(100dvh - 24px)); + max-height: calc(100dvh - 24px); padding: 16px; overflow: auto; color: var(--dsw-alias-label-primary); @@ -41,6 +41,13 @@ font-size: 13px; line-height: 1.6; max-width: calc(100vw - 24px); + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-fonts-window, --dsh-fonts-viewport; + align-self: start; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 8px 0; } .panelHeader { @@ -79,17 +86,6 @@ border-top: 0.5px solid var(--dsw-alias-border-l2); } -/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ -.panel { - position-area: bottom span-right; - position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-fonts-window, --dsh-fonts-viewport; - align-self: start; - justify-self: start; - height: fit-content; - min-height: 0; - margin: 8px 0; -} - @position-try --dsh-fonts-window { position-area: none; top: max(12px, calc(anchor(bottom) + 8px)); diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 5fb85bc259..5370f93d85 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -155,15 +155,25 @@ box-sizing: border-box; display: flex; flex-direction: column; - width: 360px; - max-width: min(calc(100vw - 32px)); - max-height: min(560px, calc(100vh - 140px)); + + max-width: calc(100vw - 32px); + max-height: min(560px, calc(100dvh - 140px)); padding: 6px; overflow: hidden; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); + /* The fixed card width lets CSS retain both the trigger edge and the window inset. */ + --dsh-subagent-menu-width: min(360px, calc(100vw - 32px)); + position-area: none; + top: calc(anchor(bottom) + 5px); + left: clamp(16px, anchor(left), calc(100vw - var(--dsh-subagent-menu-width) - 16px)); + width: var(--dsh-subagent-menu-width); + position-try-fallbacks: --dsh-subagents-above, --dsh-subagents-window; + height: fit-content; + min-height: 0; + margin: 0; } .menuBody { @@ -514,19 +524,6 @@ } } -/* The fixed card width lets CSS retain both the trigger edge and the window inset. */ -.menu { - --dsh-subagent-menu-width: min(360px, calc(100vw - 32px)); - position-area: none; - top: calc(anchor(bottom) + 5px); - left: clamp(16px, anchor(left), calc(100vw - var(--dsh-subagent-menu-width) - 16px)); - width: var(--dsh-subagent-menu-width); - position-try-fallbacks: --dsh-subagents-above, --dsh-subagents-window; - height: fit-content; - min-height: 0; - margin: 0; -} - @position-try --dsh-subagents-above { top: auto; bottom: calc(anchor(top) + 5px); diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index d9eed57dc9..24d6a0dbb8 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -74,6 +74,7 @@ export function WorkspacePickFlow({ side = 'bottom', selectedId, }: WorkspacePickFlowProps) { + // Without an owner-provided anchor, the menu remains hidden. const absentAnchor = useRef(null) const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items diff --git a/packages/experimental/session-inspector/src/client/views/inspector.module.css b/packages/experimental/session-inspector/src/client/views/inspector.module.css index ec25bed1a5..2cce7560f9 100644 --- a/packages/experimental/session-inspector/src/client/views/inspector.module.css +++ b/packages/experimental/session-inspector/src/client/views/inspector.module.css @@ -43,7 +43,25 @@ @media (hover: hover) { .typeFilterButton:hover { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); } } -.typeFilterPanel { position: fixed; z-index: 1250; width: 290px; max-width: min(calc(100vw - 24px)); max-height: min(calc(100vh - 24px)); overflow: auto; box-sizing: border-box; padding: 10px; color: var(--dsw-alias-label-primary); font-size: 11px; } +.typeFilterPanel { + position: fixed; + z-index: 1250; + width: 290px; + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 24px); + overflow: auto; + box-sizing: border-box; + padding: 10px; + color: var(--dsw-alias-label-primary); + font-size: 11px; + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-types-window, --dsh-types-viewport; + align-self: start; + justify-self: start; + height: fit-content; + min-height: 0; + margin: 4px 0; +} .typeFilterInput { width: 100%; box-sizing: border-box; padding: 5px 6px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 4px; background: transparent; color: inherit; font: inherit; } .typeFilterHelp { margin: 6px 0; color: var(--dsw-alias-label-secondary); } .typeFilterOptions { max-height: 180px; overflow: auto; } @@ -113,17 +131,6 @@ .table tr { animation: none; } } -/* CSS keeps the panel attached while the page zooms; only the layout viewport constrains its size. */ -.typeFilterPanel { - position-area: bottom span-right; - position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-types-window, --dsh-types-viewport; - align-self: start; - justify-self: start; - height: fit-content; - min-height: 0; - margin: 4px 0; -} - @position-try --dsh-types-window { position-area: none; top: max(12px, calc(anchor(bottom) + 4px)); From 48b2f9adc0e29db2c9d43b4e5f39657f29e996f0 Mon Sep 17 00:00:00 2001 From: Stevring Date: Tue, 3 Nov 2026 12:57:46 +0800 Subject: [PATCH 6/6] fix(client): keep model menu anchored during page zoom --- packages/client/ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 +- packages/client/ui-model-selection/README.zh.md | 2 +- .../ui-model-selection/src/client/ModelSelect.module.css | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index cbcfab382d..f0b4774867 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -30,8 +30,8 @@ en: 80fc237fa5c829f7 zh: ecff5c8526f45996 /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation: - en: 7ea457240e5822d0 - zh: f84e091b4e6a1965 + en: c1d14a925c92950f + zh: f80f8169bc283c60 /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 51edfde67c..ec0aba0d4e 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -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 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 prefers the position above the trigger with right edges aligned and an 8px gap, and keeps the menu 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. +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 prefers the position above the trigger with right edges aligned and an 8px gap, and keeps the menu within the layout viewport's 12px insets and desktop frame clearance. The trigger gap is preserved when desktop page zoom reflows the sticky composer. 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 e3375e6596..864d3ea9ff 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -68,7 +68,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重 ## 理解实现 -菜单采用共享 `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 读取按钮矩形或追踪视口变化。双指缩放时菜单随页面一起缩放,可以超出视觉视口,包括键盘打开时。模型名称超出面板宽度时以省略号截断,角标保持可见。模型与推理等级列表在面板高度限制内纵向滚动。 +菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。composer 菜单通过 body portal 使用 fixed 定位及 CSS Anchor Positioning,要求浏览器支持 `anchor-name`、`position-anchor` 和 `anchor()`。触发按钮和菜单共享唯一的锚点名称。CSS 优先将菜单放在按钮上方,右边缘对齐、间距为 8px,并将菜单限制在布局视口的 12px 内边距及桌面窗口边框间距内。桌面页面缩放导致 sticky 输入区重新布局时,面板仍保持与触发器的间距。定位不通过 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 072e3e13c4..9ef7fc2e80 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -97,8 +97,9 @@ 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)); + /* Anchor insets stay linear because scroll compensation runs after layout. */ + right: anchor(right); + bottom: calc(anchor(top) + 8px); align-self: safe end; justify-self: safe end; margin: 0;