Merge pull request #7700 from deepseek-harness/refactor/css-anchored-panels

refactor(client): position floating panels with CSS anchors
This commit is contained in:
imccyu
2026-11-03 15:43:31 +08:00
committed by GitHub
91 changed files with 1550 additions and 1171 deletions
+26 -7
View File
@@ -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()
+25 -1
View File
@@ -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())
+3 -3
View File
@@ -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,
+64
View File
@@ -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<void> {
await page.setContent(`<style>${css}</style><style>:root{--dsh-frame-overlay-top:${placement.clearance}px}</style>
<button style="position:fixed;left:${placement.x}px;top:${placement.y}px;width:24px;height:28px;anchor-name:--test-menu">Anchor</button>
<div role="menu" class="list portal scrollable" data-anchor-side="${placement.side}" data-anchor-align="${placement.align}"
style="position-anchor:--test-menu;width:${placement.width}px;height:${placement.height}px">Menu</div>`)
}
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()
}
})
})
+80 -38
View File
@@ -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()
+37
View File
@@ -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 = [
+95 -20
View File
@@ -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<void> {
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<void> {
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<void> {
/** Labels retain ellipsis styling without increasing the list's width. */
async function expectEllipsisStyle(labels: Locator): Promise<void> {
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([])
@@ -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
@@ -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.
@@ -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、焦点恢复和选择。断言应允许超出视觉视口,同时保留布局视口约束及内容访问能力。
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 应答。
@@ -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 (
<span ref={rootRef} className={css.anchor} data-composer-stat={stat}>
<span ref={rootRef} style={{ anchorName }} className={css.anchor} data-composer-stat={stat}>
{/* The open dialog already shows every figure; a tooltip would cover it. */}
<Tooltip label={text} {...FULL_READING_TOOLTIP} disabled={open}>
<button
@@ -226,7 +226,7 @@ function DialogPill({ stat, icon, label, text, title, titleValue, children }: Pi
className={dialogCss.panel}
role="dialog"
aria-label={title}
style={pos ?? MEASURE_STYLE}
style={{ positionAnchor: anchorName }}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
@@ -5,7 +5,7 @@ import { IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitive
import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
import type { ChatViewSlotProps } from '../contract/slots.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 './TurnUsagePanel.module.css'
import dialogCss from './stat-dialog.module.css'
@@ -29,7 +29,7 @@ function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
* @returns The trigger and, while open, its portaled dialog anchored above the trigger.
*/
export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
const { open, setOpen, rootRef, panelRef, anchorName } = useStatDialog()
const cacheHit = usage.cacheReadTokens === undefined
? null
@@ -38,7 +38,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
return (
<span ref={rootRef} className={css.root}>
<span ref={rootRef} style={{ anchorName }} className={css.root}>
<button
type="button"
className={css.trigger}
@@ -55,7 +55,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
className={dialogCss.panel}
role="dialog"
aria-label={t('message.turnUsage.title')}
style={pos ?? MEASURE_STYLE}
style={{ positionAnchor: anchorName }}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
@@ -1,12 +1,5 @@
/* Stat-dialog surface skin shared by the Turn-stat pills (TurnUsagePanel) and
the session stats pills (StatsPills.tsx): menu surface, elevation-prominent
— ContextMeter's panel skin. Placement comes from useStatDialog
(stat-dialog.ts). */
/* Portal surface: fixed in the viewport, left/top supplied inline from the
anchored-position clamp so the panel keeps its 12px viewport margin instead
of hanging off the trigger and clipping at the window edge. Portaled panels
layer above modal overlays (z 1000). */
/* Shared surface for TurnUsagePanel and the session statistics pills.
CSS anchors place the portaled panel above its trigger, with a 12px window inset. */
.panel {
position: fixed;
z-index: 1100;
@@ -15,8 +8,8 @@
Both bounds yield to a viewport narrower than themselves (12px margins),
so a fixed floor cannot push the panel past the placement clamp. */
width: max-content;
min-width: min(300px, calc(100vw - 24px), var(--dsh-anchored-max-width, 100vw));
max-width: min(440px, calc(100vw - 24px), var(--dsh-anchored-max-width, 100vw));
min-width: min(300px, calc(100vw - 24px));
max-width: min(440px, calc(100vw - 24px));
padding: 16px;
border: 0;
border-radius: var(--dsw-radius-lg);
@@ -28,11 +21,18 @@
line-height: 18px;
color: var(--dsw-alias-label-secondary);
cursor: default;
max-height: var(--dsh-anchored-max-height, none);
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
@@ -98,3 +98,22 @@
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
@position-try --dsh-stat-window {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 8px));
left: 12px;
right: 12px;
margin: 0;
align-self: safe end;
justify-self: safe end;
}
@position-try --dsh-stat-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;
}
@@ -2,49 +2,28 @@
// (TurnUsagePanel) and the composer-dock session stats pills (StatsPills.tsx);
// the matching dialog surface skin lives in stat-dialog.module.css.
import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import { useEffect, useId, useRef, useState, type MutableRefObject } from 'react'
import { useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
/** Viewport margin the placement clamp keeps (the Menu portal margin). */
const PANEL_MARGIN = 12
/** Distance between the trigger's top edge and the panel's bottom. */
const PANEL_GAP = 8
/**
* Unplaced portal panel: hidden but laid out so the clamp measures real
* dimensions (the `useAnchoredPosition` measure pass).
*/
export const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/** Open state, refs, and clamped placement for one stat dialog. */
/** Open state, refs, and CSS anchor identity for one stat dialog. */
export interface StatDialogSeat {
open: boolean
setOpen: (open: boolean) => void
rootRef: MutableRefObject<HTMLSpanElement | null>
panelRef: MutableRefObject<HTMLDivElement | null>
pos: CSSProperties | null
anchorName: string
}
/**
* One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close.
* @returns the seat; spread `pos ?? MEASURE_STYLE` onto the portaled panel.
* One trigger-anchored dialog seat: open state, CSS anchor placement, outside-close.
* @returns the seat; assign the anchor name to the trigger and the portaled panel.
*/
export function useStatDialog(): StatDialogSeat {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
// Portal placement: the dialog is fixed above the trigger and clamped inside
// the viewport, so a trigger near the window edge cannot push it off-screen.
const pos = useAnchoredPosition({
open,
anchorRef: rootRef,
panelRef,
side: 'top',
gap: PANEL_GAP,
margin: PANEL_MARGIN,
})
const anchorName = `--dsh-stat-${useId().replaceAll(':', '')}`
// Outside pointerdown closes through the shared primitive; the portaled
// panel counts as inside. An outside click also closes, because keyboard
@@ -72,5 +51,5 @@ export function useStatDialog(): StatDialogSeat {
}
}, [open, setOpen])
return { open, setOpen, rootRef, panelRef, pos }
return { open, setOpen, rootRef, panelRef, anchorName }
}
@@ -48,7 +48,7 @@ export function MenuOverlay({ useStore, actions, renderSlot, close, select, retu
return <>
<Menu key={occurrence} open={target !== null} anchor={null} portal autoFocus contextual returnFocus={returnFocus}
exiting={closing ? replacing ? 'replace' : 'dismiss' : undefined}
getAnchorRect={() => new DOMRect(point.x, point.y, 0, 0)}
anchorPoint={point}
items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)}
footer={menuEntries(items.filter(item => item.afterContent), catalog, renderSlot)} onClose={close} onSelect={select}>
{target !== null && renderSlot('contextMenu.content', { target, dismiss: close }, { entryKey: target.kind })}
@@ -21,8 +21,8 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 3fa2fb91ad383c05
zh: 1e1edd7f9a830377
en: 04b3ff9e2bdd5eb2
zh: 38daec55d2e75ac3
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: 59cd63057dfb6eeb
zh: b2e2c86e810b7bb8
+1 -1
View File
@@ -80,7 +80,7 @@ The context meter follows the [statistics-control radius](../../../docs/ui-radiu
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. Outside the Hero, that row keeps one reading's height while it is empty, so statistics and context usage arriving later do not change the composer's height; the Hero shows no empty row.
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a CSS-anchored panel bounded by the layout viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. Browser zoom and panning move the panel with the page, so it may extend beyond the visual viewport. Outside the Hero, that row keeps one reading's height while it is empty, so statistics and context usage arriving later do not change the composer's height; the Hero shows no empty row.
With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
+1 -1
View File
@@ -80,7 +80,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。Hero 之外,这一行为空时仍保留一项读数的高度,因此会话统计和上下文用量稍后到达不会改变 composer 的高度;Hero 不显示空行。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在受布局视口约束的 CSS 锚定面板中查看 token 构成,没有统计项时也可打开;上下文用量和容量尚不可用时,按钮保持隐藏。浏览器缩放和平移时面板随页面移动,因此可以超出视觉视口。Hero 之外,这一行为空时仍保留一项读数的高度,因此会话统计和上下文用量稍后到达不会改变 composer 的高度;Hero 不显示空行。
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
@@ -65,12 +65,19 @@
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: default;
max-width: var(--dsh-anchored-max-width, none);
max-height: var(--dsh-anchored-max-height, none);
max-width: calc(100vw - 24px);
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 {
@@ -164,3 +171,22 @@
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
@position-try --dsh-context-window {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 8px));
left: 12px;
right: 12px;
margin: 0;
align-self: safe end;
justify-self: safe start;
}
@position-try --dsh-context-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;
}
@@ -4,12 +4,12 @@
* Renders nothing until a provider reports both pressure and a route
* capacity. */
import { useEffect, useRef, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
// Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
import type {} from '@deepseek-ai/dsh-token-meter/client'
import { Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import { Tooltip, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { contextOccupancy } from '../context-occupancy.ts'
import css from './ContextMeter.module.css'
@@ -61,14 +61,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
const panelRef = useRef<HTMLDivElement | null>(null)
const context = contextOccupancy(pressure)
const available = context !== null
const position = useAnchoredPosition({
open: open && available,
anchorRef: rootRef,
panelRef,
side: 'top',
gap: 8,
margin: 12,
})
const anchorName = `--dsh-context-${useId().replaceAll(':', '')}`
useDismissOnOutsidePointer(rootRef, open && available, setOpen, panelRef)
// A model switch can temporarily remove capacity while this component stays
@@ -106,7 +99,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
const segments = parts.filter(part => part.width > 0)
return (
<span ref={rootRef} className={css.root}>
<span ref={rootRef} style={{ anchorName }} className={css.root}>
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open} portal>
<button
type="button"
@@ -134,7 +127,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
<div
ref={panelRef}
className={css.panel}
style={position ?? { visibility: 'hidden', left: 0, top: 0 }}
style={{ positionAnchor: anchorName }}
role="dialog"
aria-label={t('context.used')}
>
+2 -2
View File
@@ -24,8 +24,8 @@
en: c34dad62fee63306
zh: 167d9da7928a9488
/deepseek-ai-dsh-client-ui-jobs/understand-the-implementation:
en: 330e10c11d24b8c4
zh: f11736a4a9b9a94a
en: 640e8bd39b28c07d
zh: 86c7eb39acfeebc0
/deepseek-ai-dsh-client-ui-jobs/further-exploration:
en: 01eaaecb5850f391
zh: 2eabd14cde191ab8
+1 -1
View File
@@ -49,7 +49,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
<details>
<summary>Implementation internals — click to expand</summary>
One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. `useAnchoredPosition` tracks scrolling, Visual Viewport panning, browser zoom, and anchor/panel resizing; the popover stays hidden until its first position resolves. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. CSS anchors keep the popover attached to its trigger within layout-viewport size limits. Browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Its internal scrolling and dismissal remain available. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
| File | Role |
|---|---|
+1 -1
View File
@@ -49,7 +49,7 @@ kind: "package-reference"
<details>
<summary>实现内幕——点击展开</summary>
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。`useAnchoredPosition` 跟踪滚动、Visual Viewport 平移、浏览器缩放及锚点和面板尺寸变化;弹层在首次定位完成前保持隐藏。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。CSS 锚点让弹层跟随触发器,并按布局视口限制尺寸。浏览器缩放和平移时弹层随页面移动,因此可以超出视觉视口。内部滚动和关闭操作保持可用。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
| 文件 | 角色 |
|---|---|
@@ -80,9 +80,7 @@
}
}
/* Portaled and fixed in the viewport with coordinates supplied inline from the
trigger rect; the z-index layers above the right sidebar (at most 40) and
below modal overlays (1000). */
/* Portaled above the right sidebar (at most 40) and below modal overlays (1000). */
.menu {
position: fixed;
z-index: 100;
@@ -90,13 +88,20 @@
display: flex;
flex-direction: column;
width: 500px;
max-width: min(560px, calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw));
max-height: min(480px, calc(100vh - 140px), var(--dsh-anchored-max-height, 100vh));
max-width: min(560px, calc(100vw - 32px));
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 {
@@ -446,3 +451,22 @@
.noticeError {
color: var(--dsw-alias-state-error-primary);
}
@position-try --dsh-jobs-window {
position-area: none;
top: max(12px, calc(anchor(bottom) + 5px));
bottom: 12px;
left: 12px;
right: 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
}
@position-try --dsh-jobs-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;
}
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import { createPortal } from 'react-dom'
import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronDownOutlineRegular, IconLayersOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, Tooltip,
useAnchoredPosition, useDismissOnOutsidePointer,
useDismissOnOutsidePointer,
type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -48,15 +48,6 @@ export type JobListActionProps =
/** Stable empty list so a session with no jobs keeps one array identity. */
const NO_JOBS: readonly JobView[] = []
/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */
const VIEWPORT_MARGIN = 12
/** Distance between the trigger's bottom edge and the popover. */
const MENU_GAP = 5
/** Unplaced list: hidden but laid out so the first placement measures real dimensions. */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/** Keyboard-reachable controls inside the list, in document order. */
const FOCUSABLE = 'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'
@@ -416,12 +407,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
// Timers belong to this mounted session and cannot survive a disappearing trigger.
useEffect(() => cancelHover, [cancelHover, sessionId, hasJobs])
// The popover portals to the document body: the session header sits in a
// clipping column that the right sidebar borders or overlaps, so an in-place
// popover is cut off or covered there.
const menuPosition = useAnchoredPosition({
open, anchorRef: rootRef, panelRef: menuRef, gap: MENU_GAP, margin: VIEWPORT_MARGIN,
})
const anchorName = `--dsh-jobs-${useId().replaceAll(':', '')}`
useDismissOnOutsidePointer(rootRef, open, changeOpen, menuRef)
// Hover does not move focus; Escape must also work from the rest of the page.
useEffect(() => {
@@ -579,7 +565,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
)
return (
<div ref={rootRef} onKeyDown={onKeyDown}>
<div ref={rootRef} style={{ anchorName }} onKeyDown={onKeyDown}>
<Tooltip label={countLabel} disabled={open} delayMs={500} portal side="bottom">
<button
ref={triggerRef}
@@ -611,7 +597,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
<MenuSurface
ref={menuRef}
className={css.menu}
style={menuPosition ?? MEASURE_STYLE}
style={{ positionAnchor: anchorName }}
onPointerEnter={hoverEnter}
onPointerLeave={hoverLeave}
onPointerDownCapture={() => { changeOpen(true) }}
@@ -617,75 +617,60 @@ describe('JobListAction observation', () => {
expect(writeText).toHaveBeenCalledWith('pnpm run build')
})
it('anchors the portaled list in viewport coordinates and follows resize and ancestor scroll', () => {
vi.stubGlobal('innerWidth', 700)
vi.stubGlobal('innerHeight', 600)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120)
it('anchors the portaled list with CSS without reading geometry on resize or ancestor scroll', () => {
const { container } = render(<JobListAction {...props([outputJob()])} />)
const trigger = screen.getByRole('button', { name: '1 个后台任务' })
const anchor = vi.spyOn(trigger.parentElement!, 'getBoundingClientRect')
.mockReturnValue(new DOMRect(344, 40, 180, 28))
const anchor = trigger.closest('div')!
const measure = vi.spyOn(anchor, 'getBoundingClientRect')
fireEvent.click(trigger)
const list = screen.getByRole('list', { name: zh['list.aria'] })
const menu = list.closest<HTMLElement>('[data-menu-material]')
expect(menu?.parentElement).toBe(document.body)
const menu = list.closest<HTMLElement>('[data-menu-material]')!
expect(menu.parentElement).toBe(document.body)
expect(container.contains(list)).toBe(false)
expect(menu?.style.left).toBe('248px')
expect(menu?.style.top).toBe('73px')
vi.stubGlobal('innerWidth', 900)
expect(anchor.style.anchorName).not.toBe('')
expect(menu.style.positionAnchor).toBe(anchor.style.anchorName)
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
fireEvent(window, new Event('resize'))
expect(menu?.style.left).toBe('344px')
anchor.mockReturnValue(new DOMRect(80, 110, 180, 28))
fireEvent.scroll(container)
expect(menu?.style.left).toBe('80px')
expect(menu?.style.top).toBe('143px')
expect(menu.style.positionAnchor).toBe(anchor.style.anchorName)
expect(measure).not.toHaveBeenCalled()
})
it('keeps a near-edge anchored list inside both viewport margins', () => {
vi.stubGlobal('innerWidth', 700)
vi.stubGlobal('innerHeight', 600)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120)
render(<JobListAction {...props([outputJob()])} />)
const trigger = screen.getByRole('button', { name: '1 个后台任务' })
vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28))
fireEvent.click(trigger)
const menu = screen.getByRole('list', { name: zh['list.aria'] })
.closest<HTMLElement>('[data-menu-material]')
expect(menu?.style.left).toBe('12px')
expect(menu?.style.top).toBe('468px')
it('gives independent job controls separate CSS anchors', () => {
render(<><JobListAction {...props([outputJob()])} /><JobListAction {...props([outputJob()])} /></>)
const triggers = screen.getAllByRole('button', { name: '1 个后台任务' })
const first = triggers[0]!.closest('div')!
const second = triggers[1]!.closest('div')!
expect(first.style.anchorName).not.toBe('')
expect(second.style.anchorName).not.toBe('')
expect(first.style.anchorName).not.toBe(second.style.anchorName)
})
it('follows Visual Viewport panning and resizing and releases its listeners when closed', () => {
it('keeps visual viewport changes out of inline placement and closes normally', () => {
const viewport = Object.assign(new EventTarget(), { width: 600, height: 500, offsetLeft: 40, offsetTop: 60 })
vi.stubGlobal('visualViewport', viewport)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80)
const remove = vi.spyOn(viewport, 'removeEventListener')
const listen = vi.spyOn(viewport, 'addEventListener')
render(<JobListAction {...props([outputJob()])} />)
const trigger = screen.getByRole('button', { name: '1 个后台任务' })
vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 300, 200, 20))
fireEvent.click(trigger)
const menu = screen.getByRole('list', { name: zh['list.aria'] }).closest<HTMLElement>('[data-menu-material]')!
expect(menu.style.left).toBe('140px')
expect(menu.style.top).toBe('385px')
const placement = menu.style.positionAnchor
act(() => {
viewport.offsetLeft = 100
viewport.offsetTop = 120
viewport.dispatchEvent(new Event('scroll'))
})
expect(menu.style.left).toBe('200px')
expect(menu.style.top).toBe('445px')
act(() => {
viewport.height = 200
viewport.dispatchEvent(new Event('resize'))
})
expect(menu.style.top).toBe('228px')
expect(menu.style.positionAnchor).toBe(placement)
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
expect(menu.style.getPropertyValue('--dsh-anchored-max-width')).toBe('')
expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe('')
expect(listen).not.toHaveBeenCalled()
fireEvent.click(trigger)
expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull()
expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['scroll', 'resize']))
})
it('keeps portaled controls interactive and releases observation on dismissal and unmount', () => {
@@ -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: c1d14a925c92950f
zh: f80f8169bc283c60
/deepseek-ai-dsh-client-ui-model-selection/further-exploration:
en: 14acd4692e923480
zh: ff89aed852aa0da8
+2 -2
View File
@@ -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
<a id="understand-the-implementation"></a>
## 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 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.
<details>
<summary>Implementation internals — click to expand</summary>
@@ -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)字重
<a id="understand-the-implementation"></a>
## 理解实现
菜单采用共享 `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 内边距及桌面窗口边框间距内。桌面页面缩放导致 sticky 输入区重新布局时,面板仍保持与触发器的间距。定位不通过 JavaScript 读取按钮矩形或追踪视口变化。双指缩放时菜单随页面一起缩放,可以超出视觉视口,包括键盘打开时。模型名称超出面板宽度时以省略号截断,角标保持可见。模型与推理等级列表在面板高度限制内纵向滚动。
<details>
<summary>实现细节——点击展开</summary>
@@ -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,29 @@
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;
/* 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;
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(100dvh - 96px));
overflow: hidden;
padding: 4px;
/* Surface tokens match the Menu primitive card (ui-primitives
@@ -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<HTMLInputElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const groupsRef = useRef<HTMLDivElement | null>(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}
@@ -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(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={createSnapshotStore(state())}
load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
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(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={createSnapshotStore(state())}
load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
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', () => {
@@ -18,8 +18,8 @@
en: 01ba764b51f010e8
zh: e43a158a5b09d987
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: cd4e17d62c407541
zh: 9610d3ee55c0e56a
en: 1fc6c5c2a54d8db4
zh: 9e53f5164e2b7161
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/updating-a-registry-bundle:
en: 9312d8b1b381378f
zh: 8d0ae7ad26ada2ac
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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. */
@@ -979,12 +978,19 @@
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
min-width: 0;
max-width: var(--dsh-anchored-max-width, none);
max-height: var(--dsh-anchored-max-height, none);
max-width: calc(100vw - 24px);
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;
@@ -2248,3 +2252,22 @@ button.versionSummary:focus-visible {
font-size: 16px;
}
}
@position-try --dsh-registry-window {
position-area: none;
top: max(12px, calc(anchor(bottom) + 6px));
bottom: 12px;
left: 12px;
right: 12px;
margin: 0;
align-self: safe start;
justify-self: safe end;
}
@position-try --dsh-registry-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;
}
@@ -17,7 +17,7 @@ import {
IconDownloadOutlineRegular, IconInfoOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
IconWarningOutlineRegular, Input, Menu, MenuItemButton, Modal, pointerModality,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
SegmentedTabs, StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer,
SegmentedTabs, StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useDismissOnOutsidePointer,
type IconProps, type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
@@ -1043,13 +1043,10 @@ function InstallDialog({
const registryShown = install.registryOpen && phase === 'idle'
const specComposition = useInstallComposition(install.open && phase === 'idle')
const registryComposition = useInstallComposition(install.open && registryShown)
const registryPosition = useAnchoredPosition({
open: registryShown, anchorRef: registryToggleRef, panelRef: registryPanelRef, align: 'end', gap: 6, margin: 12,
})
const registryReady = registryShown && registryPosition !== null
const registryAnchorName = `--dsh-registry-${registryId.replaceAll(':', '')}`
useEffect(() => {
if (registryReady && install.registryError) registryCustomRef.current?.focus()
}, [registryReady, install.registryError])
if (registryShown && install.registryError) registryCustomRef.current?.focus()
}, [registryShown, install.registryError])
// The hook only ever asks to close.
useDismissOnOutsidePointer(registryToggleRef, registryShown, onToggleRegistry, registryPanelRef)
useEffect(() => {
@@ -1199,6 +1196,7 @@ function InstallDialog({
</button>
<button
ref={registryToggleRef}
style={{ anchorName: registryAnchorName }}
type="button"
className={css.registryToggle}
aria-expanded={install.registryOpen}
@@ -1248,7 +1246,7 @@ function InstallDialog({
ref={registryPanelRef}
id={registryId}
className={css.registry}
style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }}
style={{ positionAnchor: registryAnchorName }}
data-install-registry
aria-label={t('registryLegend')}
onKeyDown={(event) => {
@@ -18,23 +18,23 @@
en: ead6be55c7c9d7cf
zh: 125ad36db7328551
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 8c99c97e369192cb
zh: f263851ad2a7ba2e
en: eeff2dabd2f7dcb1
zh: a5054d09940b40e8
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 0d756c1e8207181d
zh: 921ce997c6b14823
en: 8aa561d0c21f177f
zh: 8c1924a20f78f3ff
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: e2649937f077d4e0
zh: 6ad604cb5adfc16b
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 81e58c7b342baf3c
zh: 6cd47b16124b215c
en: 3fd7072b662eed0a
zh: 88c6c299c0a6f8cd
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
en: 6fbd9df222b41e09
zh: 3bdb4a6b9b6bff9e
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: fd510d34404eb090
zh: d05b0890db929ae6
en: a39e67d79e171221
zh: 593e260c073336f9
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality:
en: cb61e1a37d6c27e5
zh: 1f3fa79dd5ab2e45
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -157,7 +157,7 @@ function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }:
void onCopy(target, mode as CopyMode)
}}
onClose={onClose}
getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()}
anchorRef={buttonRef}
/>
)}
</span>
@@ -30,8 +30,8 @@
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));
}
/* Legacy slots can render no rows; keep empty context popups out of layout. */
@@ -39,16 +39,84 @@
display: none;
}
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
* anchor rect (side/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. */
/* 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;
top: auto;
left: auto;
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: 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'] {
top: var(--dsh-menu-window-top);
bottom: max(12px, calc(anchor(top) + 4px));
align-self: end;
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-align='end']:not([data-anchor-side='right']) {
left: 12px;
right: max(12px, anchor(right));
justify-self: end;
--dsh-menu-wide-align: start;
}
.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 {
top: max(var(--dsh-menu-window-top), calc(anchor(bottom) + 4px));
bottom: 12px;
left: 12px;
right: 12px;
align-self: start;
justify-self: safe var(--dsh-menu-wide-align, end);
}
@position-try --dsh-menu-wide-above {
top: var(--dsh-menu-window-top);
bottom: max(12px, calc(anchor(top) + 4px));
left: 12px;
right: 12px;
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 {
top: var(--dsh-menu-window-top);
left: 12px;
right: 12px;
bottom: 12px;
align-self: safe start;
justify-self: safe start;
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
.sideTop {
@@ -61,18 +129,10 @@
right: 0;
}
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
* (the portal MARGIN in Menu.tsx; the top side widens to the frame's published
* overlay inset) and taller content scrolls inside .viewport, so a pinned
* .footer stays visible. The dynamic unit, not `vh`: mobile browser chrome
* shrinks the visible height while `vh` keeps the taller bars-hidden height, so
* a `vh` cap exceeds the viewport the placement clamps into and the card ends
* up taller than the screen showing it. Menus with submenu rows skip this
* class — the overflow clip would crop the side card, so they rely on staying
* short. */
/* Tall lists scroll within the layout viewport, independently of pinch zoom. */
.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(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
}
.viewport {
@@ -149,7 +209,7 @@
.list.compactList,
.submenu.compactList {
min-width: min(156px, var(--dsh-anchored-max-width, 100vw));
min-width: min(156px, calc(100vw - 24px));
padding: 4px;
}
@@ -318,6 +378,6 @@
.list[data-menu-phase='dismiss'],
.list[data-menu-phase='replace'] { pointer-events: none; }
.list[data-context-menu].scrollable {
max-height: min(calc(100vh - 24px), var(--dsh-anchored-max-height, 100vh));
max-height: min(calc(100dvh - 24px), var(--dsh-anchored-max-height, 100vh));
max-height: calc(100vh - 24px);
max-height: calc(100dvh - 24px);
}
+74 -48
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode, SyntheticEvent } from 'react'
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode, RefObject, SyntheticEvent } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCheckOutlineRegular } from './icons/index.tsx'
import { useAnchoredPosition } from './useAnchoredPosition.ts'
import { usePointerGrace } from './pointer-grace.ts'
import { isBehindModal } from './useModalLayer.ts'
import { observeComposition } from './keyboard-composition.ts'
@@ -152,9 +151,6 @@ function iconColumns(entries: readonly MenuEntry[], contextual: boolean): Readon
return aligned
}
/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Render an anchored dropdown menu. While the list is open its keys mirror the
* composer's: Tab settles the focused row — from the trigger, Tab enters the
@@ -178,8 +174,8 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* @param props.align - list alignment against the anchor (default 'start').
* @param props.side - open below (`bottom`, default) or above (`top`) the anchor.
* @param props.portal - render the list into document.body, fixed-positioned
* synchronously (follows movement, resizing, and browser zoom while open)
* and clamped inside the visual viewport. Use when an ancestor's overflow clipping would crop the
* with CSS anchor positioning; it follows browser zoom and may exceed the
* visual viewport. Use when an ancestor's overflow clipping would crop the
* in-place list; default false keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list once the pointer has left
* both trigger and list for the pointer grace (default false keeps it open
@@ -187,12 +183,8 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* gap and a brief overshoot survivable; coming back cancels the close.
* @param props.dense - reduce vertical row spacing without changing the standard typography or card width.
* @param props.compact - use reduced menu typography and spacing.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called while tracking the
* anchor and on scroll/resize; return null to defer placement.
* @param props.anchorRef - external element used as the CSS anchor in portal mode.
* @param props.anchorPoint - client viewport coordinates captured when a context menu opens.
* @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; contextual menus reuse an explicit leading separator. These rows stay visible while the items above scroll.
@@ -210,7 +202,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* document.body outside the owner's DOM subtree.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: {
export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', anchorRef, anchorPoint, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: {
open: boolean
/** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */
contextual?: boolean
@@ -234,7 +226,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
dense?: boolean
compact?: boolean
selection?: 'check' | 'fill'
getAnchorRect?: () => DOMRect | null
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Client coordinates captured for this opening; Safari offsets are applied once. */
anchorPoint?: { readonly x: number; readonly y: number } | undefined
minWidthFromAnchor?: boolean
className?: string | undefined
listClassName?: string | undefined
@@ -242,6 +236,17 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const rootRef = useRef<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const pointX = anchorPoint?.x
const pointY = anchorPoint?.y
const point = useMemo(() => {
if (pointX === undefined || pointY === undefined) return undefined
// WebKit reports client coordinates from the visual origin; fixed CSS uses
// the layout origin. Subsequent panning must not move this captured point.
const visualOrigin = open && typeof window.CSS !== 'undefined'
&& window.CSS.supports('-webkit-backdrop-filter', 'none')
const viewport = visualOrigin ? window.visualViewport : undefined
return { x: pointX + (viewport?.offsetLeft ?? 0), y: pointY + (viewport?.offsetTop ?? 0) }
}, [open, pointX, pointY])
useLayoutEffect(() => {
const trigger = returnFocus?.()
if (!open || !contextual || trigger == null) return
@@ -270,7 +275,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
* @param navigation - whether explicit keyboard traversal should retain its focus indicator.
*/
const refocusAnchor = (navigation = false): void => {
const trigger = returnFocus?.() ?? triggerRef.current
const remembered = triggerRef.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<HTMLButtonElement>('button:not(:disabled)')
if (target == null) return
@@ -300,30 +306,43 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
const close = useRef(onClose)
close.current = onClose
// Keep the dismissal fade at the last interactive anchor position.
const lastAnchorRect = useRef<DOMRect | null>(null)
const readAnchor = useCallback(() => {
const anchorElement = returnFocus?.() ?? rootRef.current
if (anchorElement?.closest('[inert], [hidden]') != null) {
if (exitingRef.current === undefined) close.current()
return lastAnchorRect.current
const anchorName = `--dsh-menu-trigger-${menuId.replaceAll(':', '')}`
const [externalAnchor, setExternalAnchor] = useState<HTMLElement | null>(null)
const positioned = !portal || anchorRef === undefined || externalAnchor !== null
// 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
}
lastAnchorRect.current = getAnchorRect === undefined ? rootRef.current?.getBoundingClientRect() ?? null : getAnchorRect()
return lastAnchorRect.current
}, [getAnchorRect, returnFocus])
const fixedPos = useAnchoredPosition({
open: open && portal,
anchorRef: rootRef,
panelRef: listRef,
side,
align,
gap: 4,
margin: 12,
frameClearance: !contextual,
minWidthFromAnchor,
getAnchorRect: readAnchor,
const element = anchorRef.current
setExternalAnchor(element)
if (element === null) return
const previous = element.style.getPropertyValue('anchor-name')
const priority = element.style.getPropertyPriority('anchor-name')
element.style.setProperty('anchor-name', anchorName)
return () => { element.style.setProperty('anchor-name', previous, priority) }
})
const positioned = !portal || fixedPos !== null
useEffect(() => {
if (!open || !portal || exiting !== undefined) 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 => {
const current = currentAnchor()
if (dismissed || (current !== null && current.isConnected && current.closest('[inert], [hidden]') === null)) return
dismissed = true
close.current()
}
const observer = new MutationObserver(check)
observer.observe(element.ownerDocument.documentElement, {
subtree: true, childList: true, attributes: true, attributeFilter: ['inert', 'hidden'],
})
check()
return () => { observer.disconnect() }
}, [open, portal, exiting, anchorRef, externalAnchor, returnFocus])
const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
// Opening remembers where the keyboard was, so closing can hand it back to
@@ -333,17 +352,18 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
useEffect(() => {
if (!open) return
const active = document.activeElement
triggerRef.current = active instanceof HTMLElement && (getAnchorRect !== 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])
useEffect(() => {
if (!open || exiting !== undefined || !autoFocus || !positioned
if (!open || !positioned || exiting !== undefined || !autoFocus
|| listRef.current?.contains(document.activeElement)) return
const first = listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')
walkIndex.current = first === undefined || first === null ? null : 0
if (first != null) focusWithoutRing(first)
}, [open, autoFocus, items.length, footer?.length, positioned, exiting])
}, [open, autoFocus, items.length, footer?.length, exiting, positioned])
useEffect(() => {
if (!open || exiting !== undefined) {
@@ -355,7 +375,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const onPointerDown = (e: PointerEvent) => {
if (!(e.target instanceof Node) || (contextual && e.button === 2)) return
// The portaled list is outside the anchor subtree; check both.
if (rootRef.current?.contains(e.target) === true) return
if (rootRef.current?.contains(e.target) === true || anchorRef?.current?.contains(e.target) === true) return
if (listRef.current?.contains(e.target) === true) return
onClose()
}
@@ -366,7 +386,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
// row or sits on its anchor region.
const focused = document.activeElement
const insideList = listRef.current?.contains(focused) === true
const anchored = focused === returnFocus?.() || rootRef.current?.contains(focused) === true || insideList
const anchored = focused === returnFocus?.() || anchorRef?.current?.contains(focused) === true
|| rootRef.current?.contains(focused) === true || insideList
if (e.key === 'Escape' && !e.shiftKey) {
e.preventDefault()
if (e.repeat) return
@@ -457,7 +478,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
document.removeEventListener('keydown', onEscape, true)
window.removeEventListener('blur', onWindowBlur)
}
}, [open, onClose, autoFocus, contextual, exiting])
}, [open, onClose, autoFocus, contextual, exiting, anchorRef, returnFocus])
useLayoutEffect(() => {
if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor()
@@ -554,12 +575,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
setOpenSubmenuId(null)
}
// Portal lists stay hidden until their first synchronous measurement.
const list = open && (
const list = open && positioned && (
<MenuSurface compact={compact} id={contextual ? menuId : undefined}
ref={listRef}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
style={portal ? { positionAnchor: anchorName } : undefined}
data-anchor-side={portal ? side : undefined}
data-anchor-align={portal ? align : undefined}
data-match-anchor-width={portal && minWidthFromAnchor ? '' : undefined}
role={exiting === undefined ? 'menu' : undefined}
aria-hidden={exiting === undefined ? undefined : true}
data-context-menu={contextual ? '' : undefined}
@@ -601,6 +624,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
<span
ref={rootRef}
className={clsx(css.root, className)}
style={{ anchorName: anchorRef === undefined ? anchorName : undefined, ...(point === undefined ? {} : {
position: 'fixed', left: point.x, top: point.y, width: 0, height: 0,
}) }}
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
>
@@ -38,8 +38,6 @@ export type { MenuSurfaceProps } from './MenuSurface.tsx'
export { MenuGroup, observeStickyMenuGroups } from './MenuGroup.tsx'
export type { MenuItemButtonProps, MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { useAnchoredPosition } from './useAnchoredPosition.ts'
export type { AnchoredPositionOptions } from './useAnchoredPosition.ts'
export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
export { HoverCard } from './HoverCard.tsx'
export { HoverCardGroup } from './HoverCardGroup.tsx'
@@ -1,120 +0,0 @@
/** Synchronous placement for body-portaled panels with a viewport fixed-position containing block. */
import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
import { overlayTopMargin } from './overlay-top-margin.ts'
/** Inputs for {@link useAnchoredPosition}. */
export interface AnchoredPositionOptions {
/** Whether the panel is mounted and should track its anchor. */
open: boolean
/** Element whose viewport rect anchors the panel. */
anchorRef: RefObject<HTMLElement | null>
/** Body-portaled, fixed-position panel; its ancestors must not establish a containing block. */
panelRef: RefObject<HTMLElement | null>
/** Preferred anchor edge; right-side panels align their top edges. */
side?: 'top' | 'bottom' | 'right'
/** Horizontal alignment for panels above or below the anchor. */
align?: 'start' | 'end'
/** Distance from the preferred anchor edge, in CSS pixels. */
gap: number
/** Minimum viewport inset; desktop frame clearance can increase the top inset. */
margin: number
/** Reserve desktop frame clearance in addition to the margin; false keeps only the viewport margin. Defaults to true. */
frameClearance?: boolean
/** Host-supplied client rect; null hides the panel until the anchor is available. */
getAnchorRect?: () => DOMRect | null
/** Re-measure a committed content change before paint. */
layoutKey?: string | number
/** Minimum panel width follows the anchor, bounded by the visible viewport. */
minWidthFromAnchor?: boolean
}
/**
* Keep a fixed panel aligned and constrained to the visible viewport.
* Panel CSS consumes `--dsh-anchored-max-width` and `--dsh-anchored-max-height` for size limits.
* The hook owns those inline variables and optional min-width while open, restoring them on cleanup.
* @param options - Open state, element refs, placement, and optional host rect or content change key.
* @returns Fixed left/top coordinates, or null while closed or awaiting an anchor; the first measurement completes before paint.
*/
export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
const { open, anchorRef, panelRef, side = 'bottom', align = 'start', gap, margin,
getAnchorRect, layoutKey, minWidthFromAnchor = false, frameClearance = true } = options
const [position, setPosition] = useState<CSSProperties | null>(null)
useLayoutEffect(() => {
if (!open) {
setPosition(null)
return
}
const anchor = anchorRef.current
const panel = panelRef.current
if (anchor === null || panel === null) return
const win = anchor.ownerDocument.defaultView
if (win === null) return
const viewport = win.visualViewport
// WebKit client rects use the visual origin; viewport-fixed CSS uses the layout origin.
const visualOrigin = typeof win.CSS !== 'undefined' && win.CSS.supports('-webkit-backdrop-filter', 'none')
const properties = ['--dsh-anchored-max-width', '--dsh-anchored-max-height', ...minWidthFromAnchor ? ['min-width'] : []]
const previous = properties.map(name => [name, panel.style.getPropertyValue(name), panel.style.getPropertyPriority(name)] as const)
let active = true
let lastRect: Pick<DOMRect, 'x' | 'y' | 'width' | 'height'> | null = null
const readRect = () => getAnchorRect === undefined ? anchor.getBoundingClientRect() : getAnchorRect()
const place = (rect: DOMRect | null): void => {
lastRect = rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
let next: CSSProperties | null = null
if (rect !== null) {
const x = viewport?.offsetLeft ?? 0
const y = viewport?.offsetTop ?? 0
const width = viewport?.width ?? (anchor.ownerDocument.documentElement.clientWidth || win.innerWidth)
const height = viewport?.height ?? (anchor.ownerDocument.documentElement.clientHeight || win.innerHeight)
const topMargin = frameClearance ? overlayTopMargin(margin, rect, gap) : margin
const maxWidth = Math.max(0, width - margin * 2)
panel.style.setProperty('--dsh-anchored-max-width', `${maxWidth}px`)
panel.style.setProperty('--dsh-anchored-max-height', `${Math.max(0, height - topMargin - margin)}px`)
if (minWidthFromAnchor) panel.style.minWidth = `${Math.min(rect.width, maxWidth)}px`
const panelWidth = panel.offsetWidth
const panelHeight = panel.offsetHeight
const offsetX = visualOrigin ? x : 0
const offsetY = visualOrigin ? y : 0
const left = offsetX + (side === 'right' ? rect.right + gap : align === 'end' ? rect.right - panelWidth : rect.left)
const top = offsetY + (side === 'right' ? rect.top : side === 'top' ? rect.top - gap - panelHeight : rect.bottom + gap)
next = {
left: Math.max(x + margin, Math.min(left, x + width - margin - panelWidth)),
top: Math.max(y + topMargin, Math.min(top, y + height - margin - panelHeight)),
}
}
setPosition(current => current?.left === next?.left && current?.top === next?.top ? current : next)
}
const update = (): void => {
if (!active) return
place(readRect())
}
update()
win.addEventListener('scroll', update, true)
win.addEventListener('resize', update)
viewport?.addEventListener('scroll', update)
viewport?.addEventListener('resize', update)
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update)
observer?.observe(anchor)
observer?.observe(panel)
// Transforms and surrounding layout can move an anchor without resizing either element.
let frame = 0
const track = (): void => {
if (!active) return
const rect = readRect()
if (rect?.x !== lastRect?.x || rect?.y !== lastRect?.y
|| rect?.width !== lastRect?.width || rect?.height !== lastRect?.height) place(rect)
frame = win.requestAnimationFrame(track)
}
frame = win.requestAnimationFrame(track)
return () => {
active = false
win.cancelAnimationFrame(frame)
observer?.disconnect()
win.removeEventListener('scroll', update, true)
win.removeEventListener('resize', update)
viewport?.removeEventListener('scroll', update)
viewport?.removeEventListener('resize', update)
for (const [name, value, priority] of previous) panel.style.setProperty(name, value, priority)
}
}, [open, anchorRef, panelRef, side, align, gap, margin, getAnchorRect, layoutKey, minWidthFromAnchor, frameClearance])
return position
}
@@ -644,117 +644,114 @@ describe('Menu', () => {
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
})
it('portal mode prefers getAnchorRect over measuring its own wrapper', () => {
const rect = { left: 40, right: 72, top: 100, bottom: 128, width: 32, height: 28, x: 40, y: 100, toJSON: () => ({}) } as DOMRect
render(
<Menu
portal
open
getAnchorRect={() => rect}
anchor={null}
items={items}
onSelect={() => {}}
onClose={() => {}}
/>)
it('anchors a portal menu to an external element and restores its anchor name', () => {
const anchor = createRef<HTMLButtonElement>()
const view = render(<><button ref={anchor} style={{ anchorName: '--existing-anchor' }}>External</button>
<Menu portal open anchorRef={anchor} anchor={null} items={items} onClose={() => {}} /></>)
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(<><button ref={anchor} style={{ anchorName: '--existing-anchor' }}>External</button>
<Menu portal open={false} anchorRef={anchor} anchor={null} items={items} onClose={() => {}} /></>)
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(
<Menu
portal
open
getAnchorRect={() => null}
anchor={null}
items={items}
onSelect={() => {}}
onClose={() => {}}
/>)
it('waits for an external anchor before displaying the portal menu', () => {
const anchor = createRef<HTMLButtonElement>()
render(<Menu portal open anchorRef={anchor} anchor={null} items={items} onClose={() => {}} />)
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<HTMLButtonElement>()
function Fixture({ present, margin = 0 }: { present: boolean; margin?: number }) {
return <>{present && <button ref={anchor} style={{ marginLeft: margin }}>External</button>}
<Menu portal open autoFocus anchorRef={anchor} anchor={null}
items={[...items, { id: 'c', label: 'Gamma' }]} onClose={() => {}} /></>
}
const geometry = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
try {
render(<Menu portal open autoFocus getAnchorRect={() => rect} anchor={null}
items={[...items, { id: 'c', label: 'Gamma' }]} onClose={() => {}} />)
const view = render(<Fixture present={false} />)
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(<Fixture present />)
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(<Fixture present margin={80} />)
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<HTMLButtonElement>()
const second = createRef<HTMLButtonElement>()
const onClose = vi.fn()
function Fixture({ useSecond }: { useSecond: boolean }) {
return <><button ref={first}>First trigger</button><button ref={second}>Second trigger</button>
<Menu open portal anchor={null} anchorRef={useSecond ? second : first} items={items} onClose={onClose} /></>
}
const view = render(<Fixture useSecond={false} />)
view.rerender(<Fixture useSecond />)
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(<Menu portal open align="end" getAnchorRect={() => 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(<Menu portal open align="end" anchor={<button>Open</button>} 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(<Menu {...props} open />)
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(<Menu {...props} open={false} />)
getAnchorRect.mockClear()
act(() => { vi.advanceTimersToNextFrame() })
expect(getAnchorRect).not.toHaveBeenCalled()
view.rerender(<Menu {...props} open />)
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(<>
<Menu portal open anchor={<button>First</button>} items={items} onClose={() => {}} />
<Menu portal open anchor={<button>Second</button>} 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(
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
it('passes top-end placement to CSS without writing measured coordinates', () => {
render(<Menu portal open align="end" side="top" anchor={<span>trigger</span>}
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(<Menu portal open anchor={null} items={items} onSelect={() => {}} 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(
<Menu
portal
open
getAnchorRect={() => 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', () => {
@@ -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(<Menu open portal autoFocus contextual anchor={null} onClose={close}
getAnchorRect={() => 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.firstElementChild as HTMLElement
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(<Menu {...props} open anchorPoint={{ x: 200, y: 100 }} />)
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
viewport.offsetTop = 120
view.rerender(<Menu {...props} open anchorPoint={{ x: 200, y: 100 }} />)
expect(point().style.left).toBe(visualOrigin ? '240px' : '200px')
expect(point().style.top).toBe(visualOrigin ? '160px' : '100px')
view.rerender(<Menu {...props} open={false} anchorPoint={{ x: 200, y: 100 }} />)
view.rerender(<Menu {...props} open anchorPoint={{ x: 200, y: 100 }} />)
expect(point().style.left).toBe(visualOrigin ? '280px' : '200px')
expect(point().style.top).toBe(visualOrigin ? '220px' : '100px')
view.rerender(<Menu {...props} open anchorPoint={{ x: 220, y: 130 }} />)
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: <svg /> }, { id: 'second', label: 'Second' },
@@ -1,21 +1,14 @@
// @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 { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { Menu } from '../src/Menu.tsx'
let frames: Map<number, FrameRequestCallback>
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<HTMLButtonElement>(null)
@@ -27,13 +20,60 @@ it.each(['inert', 'hidden'].flatMap(attribute => [false, true].map(contextual =>
}
const view = render(<Fixture />)
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 <Menu open={open} portal anchor={null} anchorRef={anchorRef}
onClose={() => { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} />
}
render(<Fixture />)
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<HTMLButtonElement>(null)
return <>{ready && <button ref={anchor}>Late anchor</button>}
<Menu open={open} portal anchor={null} anchorRef={anchor}
onClose={() => { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /></>
}
const view = render(<Fixture ready={false} />)
expect(screen.queryByRole('menu')).toBeNull()
view.rerender(<Fixture ready />)
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<HTMLButtonElement>(null)
return <><button key={generation} ref={anchor}>Anchor {generation}</button>
<Menu open={open} portal anchor={null} anchorRef={anchor}
onClose={() => { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /></>
}
const view = render(<Fixture generation={0} />)
const original = screen.getByRole('button')
await act(async () => { view.rerender(<Fixture generation={1} />); 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(<Fixture />)
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) => {
@@ -67,3 +106,66 @@ it.each([undefined, 'existing-panel'])('restores the trigger aria-controls value
view.rerender(<Fixture open={false} />)
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<HTMLButtonElement>(null)
return <>
<button onClick={() => { setGeneration(value => value + 1) }}>Replace anchor</button>
<button key={generation} ref={anchor} onClick={() => { setOpen(true) }}>Open</button>
<Menu open={open} portal anchor={null} anchorRef={anchor}
onClose={() => { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} />
</>
}
render(<Fixture />)
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<HTMLButtonElement>(null)
const trigger = open && <button ref={anchor}>External anchor</button>
return <>
<button onClick={() => { setOpen(true) }}>Open</button>
{order === 'before' && trigger}
<Menu open={open} portal anchor={null} anchorRef={anchor}
onClose={() => { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} />
{order === 'after' && trigger}
</>
}
render(<Fixture />)
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<HTMLButtonElement>(null)
return <>
<input aria-label="Draft" onKeyDown={(event) => { if (event.key === 'F2') setOpen(true) }} />
<button ref={anchor}>Anchor</button>
<Menu open={open} portal autoFocus anchor={null} anchorRef={anchor}
onClose={() => { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} />
</>
}
render(<Fixture />)
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)
})
@@ -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<HTMLButtonElement>(null)
return <><button ref={trigger}>Tools</button>
<Menu open portal anchor={null} anchorRef={trigger}
items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} /></>
}
const view = render(<Fixture />)
const trigger = view.getByRole('button', { name: 'Tools' })
const name = view.getByRole('menu').style.positionAnchor
expect(name).not.toBe('')
expect([...document.querySelectorAll<HTMLElement>('[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: <button>Tools</button>, onClose: vi.fn() }
const view = render(<Menu {...props} minWidthFromAnchor items={[{ id: 'one', label: 'One' }]} />)
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(<Menu {...props} items={[{ id: 'one', label: 'One' }]} />)
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(<Menu open portal minWidthFromAnchor align="end" anchor={<button>Tools</button>}
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(<Menu open portal minWidthFromAnchor align="end" anchor={<button>Tools</button>}
const view = render(<Menu open portal minWidthFromAnchor align="end" anchor={<button style={{ width: 280 }}>Tools</button>}
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()
})
})
@@ -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)))',
])
})
@@ -1,55 +0,0 @@
// @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'
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(<Menu open portal side={side} anchor={<button>Open</button>} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />)
}
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('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('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')
})
})
@@ -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)
})
})
@@ -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<Parameters<typeof useAnchoredPosition>[0], 'anchorRef' | 'panelRef' | 'gap' | 'margin'>) {
const anchorRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...options })
return <>
<button ref={anchorRef} type="button">anchor</button>
{open && <div ref={panelRef} data-testid="panel" style={{ position: 'fixed', ...position ?? { visibility: 'hidden' } }} />}
</>
}
function show(props: Parameters<typeof Host>[0]) {
return render(<Host {...props} />)
}
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(<Host open={false} />)
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<typeof vi.fn> }> = []
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(<Host open={false} />)
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(<Host open />) })
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(<Host open side="top" layoutKey="model" />)
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()
})
})
+2 -2
View File
@@ -21,8 +21,8 @@
en: 4005b800e930f581
zh: ccb9318dad7f7e8f
/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation:
en: 72598a09be42251d
zh: 4140b2eec82c7f1f
en: 73e3c0f06c7381e8
zh: 4b83f0eb8da65ce1
/deepseek-ai-dsh-client-ui-schedule/further-exploration:
en: 2fd968202916b197
zh: f1ab66194846562b
+1 -1
View File
@@ -50,7 +50,7 @@ Time-zone enumeration and localized names use the browser's embedded ICU/CLDR da
<a id="understand-the-implementation"></a>
## 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.
<details>
<summary>Implementation details — click to expand</summary>
+1 -1
View File
@@ -50,7 +50,7 @@ kind: "package-reference"
<a id="understand-the-implementation"></a>
## 理解实现
任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。使用 portal 的任务菜单和日期、时间选择器通过 `useAnchoredPosition`,在浏览器缩放、滚动和尺寸变化时跟随触发器,并保留视口边距和桌面标题栏避让。视觉视口过窄时,任务菜单文案和时间值以省略号截断。时间列随面板缩窄,并在可用高度内纵向滚动;任务菜单通过纵向滚动到达每个选项。会话页头弹层遵循同样的视口限制,并在首次定位完成前保持隐藏。
任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。使用 portal 的任务菜单、日期和时间选择器以及会话页头弹层通过 CSS 锚点跟随触发器,保留布局视口尺寸限制和桌面标题栏避让。浏览器缩放和平移时,这些面板随页面移动,可以超出视觉视口,包括键盘打开时。文案超出面板宽度时以省略号截断。时间列和任务菜单选项在面板高度限制内纵向滚动。时间列上的横向手势可以平移放大后的页面,纵向滚动到边缘时不继续传递到外层。外部关闭和键盘导航保持可用。
<details>
<summary>实现内部细节 — 点击展开</summary>
@@ -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
@@ -76,6 +76,8 @@ export interface ClockPickerProps {
readonly open: boolean
/** The row's trigger button, which anchors the panel. */
readonly anchorRef: RefObject<HTMLButtonElement | null>
/** 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<string, HTMLDivElement>())
const valueRef = useRef(value)
@@ -185,6 +187,7 @@ export function ClockPicker({ open, anchorRef, value, onPick, onClose, seconds =
return <PickerPopover
open={open}
anchorRef={anchorRef}
anchorName={anchorName}
label={t('timing.time')}
className={css.clock}
onClose={onClose}
@@ -100,6 +100,8 @@ export interface DatePickerProps {
readonly open: boolean
/** The row's trigger button, which anchors the panel. */
readonly anchorRef: RefObject<HTMLButtonElement | null>
/** 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<string, HTMLDivElement>())
const valueRef = useRef(value)
valueRef.current = value
@@ -174,6 +176,7 @@ export function DatePicker({ open, anchorRef, value, onPick, onClose, t }: DateP
return <PickerPopover
open={open}
anchorRef={anchorRef}
anchorName={anchorName}
label={t('timing.date')}
className={css.calendar}
onClose={onClose}
@@ -2,8 +2,17 @@
translucent surface, portal-positioned from its trigger. */
.panel {
position: fixed;
top: auto;
left: auto;
position-area: none;
position-try-fallbacks: --dsh-schedule-picker-above, --dsh-schedule-picker-end, --dsh-schedule-picker-above-end, --dsh-schedule-picker-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;
box-sizing: border-box;
z-index: 1100;
display: flex;
@@ -17,8 +26,38 @@
every other menu card (see ui-theme styles/scrollbar.css). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
max-width: var(--dsh-anchored-max-width, none);
max-height: var(--dsh-anchored-max-height, none);
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
overflow-x: hidden;
overflow-y: auto;
}
/* Flipping preserves the window margins instead of testing against its zero edge. */
@position-try --dsh-schedule-picker-above {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 4px));
align-self: end;
}
@position-try --dsh-schedule-picker-end {
left: 12px;
right: max(12px, anchor(right));
justify-self: end;
}
@position-try --dsh-schedule-picker-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-picker-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;
}
@@ -1,20 +1,19 @@
/** Anchored, outside-dismissed popover shell the timing pickers share. */
import { useRef } from 'react'
import type { CSSProperties, ReactNode, RefObject } from 'react'
import type { ReactNode, RefObject } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import { useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PickerPopover.module.css'
/** Unplaced portal frame: laid out at the viewport origin but unpainted until measured. */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/** Inputs of the picker shell. */
export interface PickerPopoverProps {
/** Whether the panel is mounted. */
readonly open: boolean
/** The row's trigger button, which anchors the panel and counts as inside for dismissal. */
readonly anchorRef: RefObject<HTMLElement | null>
/** 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<HTMLDivElement>(null)
useDismissOnOutsidePointer(anchorRef, open, onClose, panelRef)
const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 })
if (!open) return null
return createPortal(
<div
ref={panelRef}
className={clsx(css.panel, className)}
style={position ?? MEASURE_STYLE}
style={{ positionAnchor: anchorName }}
// The rows that open a picker declare `aria-haspopup="dialog"`, so the
// panel is the dialog they announce, named by the row's own label.
role="dialog"
@@ -40,14 +40,23 @@
.menu {
position: fixed;
position-area: none;
position-try-fallbacks: --dsh-schedule-catalog-wide-below, --dsh-schedule-catalog-above, --dsh-schedule-catalog-wide-above, --dsh-schedule-catalog-window;
top: max(16px, calc(anchor(bottom) + 5px));
bottom: 16px;
left: max(16px, anchor(left));
right: 16px;
height: fit-content;
align-self: start;
justify-self: start;
z-index: 100;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1px;
width: 336px;
max-width: min(336px, calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw));
max-height: min(420px, calc(100vh - 140px), var(--dsh-anchored-max-height, 100vh));
max-width: min(336px, calc(100vw - 32px));
max-height: min(420px, calc(100vh - 140px));
margin: 0;
padding: 3px;
overflow: auto;
@@ -203,3 +212,33 @@
.trigger[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* A wide catalog first shifts horizontally without leaving its trigger's row. */
@position-try --dsh-schedule-catalog-wide-below {
left: 16px;
right: 16px;
justify-self: safe end;
}
@position-try --dsh-schedule-catalog-above {
top: max(16px, var(--dsh-frame-overlay-top, 16px));
bottom: max(16px, calc(anchor(top) + 5px));
align-self: end;
}
@position-try --dsh-schedule-catalog-wide-above {
top: max(16px, var(--dsh-frame-overlay-top, 16px));
bottom: max(16px, calc(anchor(top) + 5px));
left: 16px;
right: 16px;
align-self: end;
justify-self: safe end;
}
@position-try --dsh-schedule-catalog-window {
position-area: none;
inset: max(16px, var(--dsh-frame-overlay-top, 16px)) 16px 16px;
margin: 0;
align-self: safe end;
justify-self: safe end;
}
@@ -1,11 +1,10 @@
import {
useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
useEffect, useId, useMemo, useRef, useState, type KeyboardEvent,
} from 'react'
import { createPortal } from 'react-dom'
import {
IconClockOutlineRegular,
IconTrashOutlineRegular,
useAnchoredPosition,
useDismissOnOutsidePointer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -31,7 +30,6 @@ export type ScheduleCatalogActionProps =
}>
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<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const catalogRef = useRef<HTMLUListElement>(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 = (
<button
ref={triggerRef}
style={{ anchorName }}
type="button"
className={css.trigger}
data-schedule-reminder-entry=""
@@ -141,7 +133,7 @@ function SessionScheduleCatalog({
<ul
ref={catalogRef}
className={css.menu}
style={catalogPosition ?? MEASURE_STYLE}
style={{ positionAnchor: anchorName }}
aria-label={t('list.aria')}
onKeyDown={onKeyDown}
>
@@ -135,6 +135,8 @@ export function TaskEditorFields({ draft, onChange, projects, disabled, original
const dateAnchor = useRef<HTMLButtonElement>(null)
const clockAnchor = useRef<HTMLButtonElement>(null)
const id = useId()
const dateAnchorName = `--dsh-schedule-date-${id.replaceAll(':', '')}`
const clockAnchorName = `--dsh-schedule-clock-${id.replaceAll(':', '')}`
const rowId = (name: string): string => `${id}-${name}`
const change = (patch: Partial<TaskEditorDraft>): void => { onChange({ ...draft, ...patch }) }
const clock = draft.frequency === 'daily' ? `${draft.dailyTime}:00` : draft.start.slice(11)
@@ -206,23 +208,23 @@ export function TaskEditorFields({ draft, onChange, projects, disabled, original
</div>}
{draft.frequency === 'once' && <div className={css.ruleRow}>
<label className={css.ruleLabel} htmlFor={rowId('date')}>{t('timing.date')}</label>
<button ref={dateAnchor} id={rowId('date')} type="button" disabled={disabled}
<button ref={dateAnchor} style={{ anchorName: dateAnchorName }} id={rowId('date')} type="button" disabled={disabled}
className={clsx(css.ruleInput, css.ruleControl, css.pickerTrigger)} aria-label={t('timing.date')}
aria-haspopup="dialog" aria-expanded={dateOpen} onClick={() => { setDateOpen(true) }}>
<span>{draft.start.slice(0, 10).replaceAll('-', '/')}</span><IconCalendarOutlineRegular className={css.pickerIcon} />
</button>
<DatePicker open={dateOpen && !disabled} anchorRef={dateAnchor} value={draft.start.slice(0, 10)}
<DatePicker open={dateOpen && !disabled} anchorRef={dateAnchor} anchorName={dateAnchorName} value={draft.start.slice(0, 10)}
onPick={(date) => { change({ start: `${date}T${clock}` }); setDateOpen(false) }}
onClose={() => { setDateOpen(false) }} t={t} />
</div>}
{draft.frequency !== 'advanced' && draft.frequency !== 'minutely' && draft.frequency !== 'hourly' && <div className={css.ruleRow}>
<label className={css.ruleLabel} htmlFor={rowId('time')}>{t('timing.time')}</label>
<button ref={clockAnchor} id={rowId('time')} type="button" disabled={disabled}
<button ref={clockAnchor} style={{ anchorName: clockAnchorName }} id={rowId('time')} type="button" disabled={disabled}
className={clsx(css.ruleInput, css.ruleControl, css.pickerTrigger)} aria-label={t('timing.time')}
aria-haspopup="dialog" aria-expanded={clockOpen} onClick={() => { setClockOpen(true) }}>
<span>{clock}</span><IconClockOutlineRegular className={css.pickerIcon} />
</button>
<ClockPicker open={clockOpen && !disabled} anchorRef={clockAnchor} value={clock} seconds={draft.frequency !== 'daily'}
<ClockPicker open={clockOpen && !disabled} anchorRef={clockAnchor} anchorName={clockAnchorName} value={clock} seconds={draft.frequency !== 'daily'}
onPick={(time) => { change(draft.frequency === 'daily' ? { dailyTime: time.slice(0, 5) }
: { start: `${draft.start.slice(0, 10)}T${time}` }) }} onClose={() => { setClockOpen(false) }} t={t} />
</div>}
@@ -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. */
@@ -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;
}
@@ -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<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(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<HTMLElement>('[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 && (
<div
ref={listRef}
className={clsx(css.list, listClassName, css.scrollable, portal && css.portal, align === 'end' && !portal && css.alignEnd)}
style={portal ? position ?? MEASURE_STYLE : undefined}
className={clsx(css.list, listClassName, css.scrollable, portal && css.portal, align === 'end' && css.alignEnd)}
style={portal ? { positionAnchor: anchorName } : undefined}
role="menu"
// React portals bubble synthetic events through the React tree: without
// this stop, an item click re-fires an owner handler above the anchor.
@@ -342,7 +320,7 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo
)
return (
<span ref={rootRef} className={clsx(css.root, className)}>
<span ref={rootRef} style={{ anchorName }} className={clsx(css.root, className)}>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
</span>
@@ -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(<ScheduleCatalogAction {...props(active)} />)
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('')
})
})
@@ -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(<>
<TaskMenu open portal anchor={<button type="button">first trigger</button>} items={ITEMS} onSelect={() => {}} onClose={() => {}} />
<TaskMenu open portal anchor={<button type="button">second trigger</button>} 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)
})
@@ -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(<ClockPicker {...props} />)
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(<ClockPicker {...props} />)
@@ -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
@@ -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.
<details>
<summary>Office implementation — click to expand</summary>
@@ -109,7 +109,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本;CSV 还提供
后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。
当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开锚定字体列表,列表跟随浏览器缩放、Visual Viewport 平移及尺寸变化,并在首次定位完成后接收焦点。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。
当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开 CSS 锚定的字体列表,并将焦点移入其中。列表跟随触发器,并按布局视口限制尺寸;浏览器缩放和平移时列表随页面移动,因此可以超出视觉视口。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。
<details>
<summary>Office 实现——点击展开</summary>
@@ -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: calc(100dvh - 24px);
padding: 16px;
overflow: auto;
color: var(--dsw-alias-label-primary);
@@ -40,7 +40,14 @@
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);
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 {
@@ -78,3 +85,22 @@
padding: 8px 0;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
@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;
}
@@ -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<HTMLSpanElement>(null)
const panel = useRef<HTMLDivElement>(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 <>
<Tooltip label={label} side="bottom" delayMs={500} disabled={open}>
<span ref={anchor} className={css.anchor} data-office-font-warning>
<span ref={anchor} style={{ anchorName }} className={css.anchor} data-office-font-warning>
<button type="button" className={clsx(common.tool, css.warning)} aria-label={label}
aria-expanded={open} aria-controls={open ? id : undefined} aria-haspopup="dialog"
onClick={() => { setExpanded(value => !value) }}>
@@ -48,7 +47,7 @@ export function FontNotice({ fonts, t }: FontNoticeProps): ReactNode {
</Tooltip>
{open && createPortal(<div ref={panel} id={id} role="dialog" aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`} tabIndex={-1} className={css.panel}
style={{ ...position, visibility: position === null ? 'hidden' : undefined }}
style={{ positionAnchor: anchorName }}
onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeDetails() } }}
onBlur={(event) => {
if (event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)
+4 -4
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-subagent/use-this-package:
en: d00a6c7ffced5dc7
zh: 616220bbdfdc70a6
en: 1a2c831af628c278
zh: 42ad333c5010e708
/deepseek-ai-dsh-client-ui-subagent/use-this-package/browsing-the-tree:
en: 4ecdca030e42109d
zh: 406b08590ba8df83
@@ -30,8 +30,8 @@
en: c158f12dcaed87b7
zh: e5d315e7b1fb4fa9
/deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation:
en: 41492c7bf30f2a48
zh: b225f51bd226d60d
en: 45772c086f8a55f1
zh: c80584bf49897b8f
/deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens:
en: dcbc58d4cad47fa8
zh: e962a8cbdfd4a914
+2 -2
View File
@@ -29,7 +29,7 @@ The session header uses the session title for ordinary breadcrumbs. Subagent bre
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
Every open catalog menu is 360px wide with 6px outer padding, constrained to fit the viewport. The root menu shows noncollapsible Subagents and Shared tasks headers only when Team mode is enabled, at least one nondeleted shared task exists, and the footer contribution is available. The Subagents header uses a Users icon and counts direct entries in completed, in progress, not running, and external order; zero counts are omitted and the separator matches the input task list. External tasks never count as running or completed. An empty Subagents section shows “No subagents” only alongside shared tasks. With Team mode off or no shared tasks, the menu shows only the subagent list, without section headers, a shared-task module, empty-state copy, or a divider; a real task-record failure remains a separate alert. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; sections have no nested scrolling. In WebKit-compatible browsers, the native 5px scrollbar sits in the right 6px inset with inset track ends and a stable gutter, preserving content width when scrolling becomes necessary. Other browsers retain their standard native scrollbar.
Every open catalog menu is 360px wide with 6px outer padding, constrained to fit the layout viewport. The root menu shows noncollapsible Subagents and Shared tasks headers only when Team mode is enabled, at least one nondeleted shared task exists, and the footer contribution is available. The Subagents header uses a Users icon and counts direct entries in completed, in progress, not running, and external order; zero counts are omitted and the separator matches the input task list. External tasks never count as running or completed. An empty Subagents section shows “No subagents” only alongside shared tasks. With Team mode off or no shared tasks, the menu shows only the subagent list, without section headers, a shared-task module, empty-state copy, or a divider; a real task-record failure remains a separate alert. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; sections have no nested scrolling. In WebKit-compatible browsers, the native 5px scrollbar sits in the right 6px inset with inset track ends and a stable gutter, preserving content width when scrolling becomes necessary. Other browsers retain their standard native scrollbar.
The root catalog exposes `conversation.session.header.actions.subagent.footer` for shared tasks. Child and ancestor breadcrumb catalogs show navigation only, without section headers.
@@ -63,7 +63,7 @@ The [subagent runtime](../../subagent/subagent/README.md) owns continuation and
The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts. Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row reads its target's projections, including ordinary cold history. A list row with `formatStatus: migration-required` displays “Migration required” before expansion and keeps its history-opening actions without descendant expansion or changing its subagent mode; a projection read requiring migration without a catalog supplies the same notice. Opening the Session supplies its catalog through history and control projections. Otherwise, an available catalog remains usable, including an explicit empty catalog. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Other rows remain expandable while their catalogs are absent, loading, or failed, and become known leaves after ready empty catalogs. External rows are always leaves and do not load child Sessions.
Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. Shared anchored positioning keeps the panel inside the visible viewport when its content, the window size, or browser zoom changes.
Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. CSS anchors keep the panel attached to its trigger within layout-viewport size limits. Browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Internal scrolling, hover behavior, pinned opening, and focus restoration remain independent.
Opening a catalog dropdown does not request its root catalog. Child-catalog expansion and failed-read retries call `refreshProjection`; shared projection-value changes update the display automatically.
+2 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
每个打开的目录菜单宽度均为 360px,外层内边距为 6px,并受视口可用宽度约束。仅当 Team 模式已开启、存在至少一个未删除的共享任务且 footer 扩展可用时,根菜单才显示不可折叠的“子智能体”和“共享任务”标题。“子智能体”标题使用 Users 图标,仅统计直接条目,依次显示已完成、进行中、未运行和外部任务数量;省略零值,分隔符与输入区任务列表一致。外部任务不计入运行中或已完成数量。空的“子智能体”区域仅在同时展示共享任务时显示“暂无子智能体”。Team 模式关闭或没有共享任务时,菜单仅显示 subagent 列表,不显示区域标题、共享任务模块、空状态文案或分隔线;真实的任务记录错误仍以独立警告显示。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,各区域不单独滚动。在兼容 WebKit 的浏览器中,原生 5px 滚动条位于右侧 6px 内边距内,轨道两端内缩,并保留稳定的滚动条空间,使内容在需要滚动时保持原有宽度。其他浏览器保留标准原生滚动条。
每个打开的目录菜单宽度均为 360px,外层内边距为 6px,并受布局视口可用宽度约束。仅当 Team 模式已开启、存在至少一个未删除的共享任务且 footer 扩展可用时,根菜单才显示不可折叠的“子智能体”和“共享任务”标题。“子智能体”标题使用 Users 图标,仅统计直接条目,依次显示已完成、进行中、未运行和外部任务数量;省略零值,分隔符与输入区任务列表一致。外部任务不计入运行中或已完成数量。空的“子智能体”区域仅在同时展示共享任务时显示“暂无子智能体”。Team 模式关闭或没有共享任务时,菜单仅显示 subagent 列表,不显示区域标题、共享任务模块、空状态文案或分隔线;真实的任务记录错误仍以独立警告显示。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,各区域不单独滚动。在兼容 WebKit 的浏览器中,原生 5px 滚动条位于右侧 6px 内边距内,轨道两端内缩,并保留稳定的滚动条空间,使内容在需要滚动时保持原有宽度。其他浏览器保留标准原生滚动条。
根会话目录通过 `conversation.session.header.actions.subagent.footer` 提供共享任务扩展。子会话与祖先面包屑目录只显示导航,不显示区域标题。
@@ -63,7 +63,7 @@ kind: "package-reference"
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行读取目标投影,包括普通冷历史。列表行的 `formatStatus` 为 `migration-required` 时,行在展开前即显示“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode;投影读取需要迁移且缺少目录时,也显示同一提示。打开会话后,历史与 control 投影提供其目录。其他情况下,已有目录仍可使用,包括显式空目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。其他行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。
每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。共享锚点定位会在内容、窗口大小或浏览器缩放变化时,将面板限制在可见视口内。
每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。CSS 锚点让面板跟随触发器,并按布局视口限制尺寸。浏览器缩放和平移时面板随页面移动,因此可以超出视觉视口。内部滚动、悬停行为、点击固定展开和焦点恢复保持独立。
打开目录下拉菜单不会请求其根目录。展开子节点目录或重试失败读取时调用 `refreshProjection`;共享投影值的变化会自动更新显示。
@@ -155,15 +155,25 @@
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 360px;
max-width: min(calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw));
max-height: min(560px, calc(100vh - 140px), var(--dsh-anchored-max-height, 100vh));
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 {
@@ -513,3 +523,14 @@
background: var(--dsw-alias-interactive-bg-hover);
}
}
@position-try --dsh-subagents-above {
top: auto;
bottom: calc(anchor(top) + 5px);
}
@position-try --dsh-subagents-window {
inset: max(16px, var(--dsh-frame-overlay-top, 16px)) 16px 16px;
align-self: safe start;
justify-self: safe start;
}
@@ -8,7 +8,7 @@ import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronRightOutlineRegular, IconRefreshOutlineRegular, IconUsersOutlineRegular,
IconCheckOutlineRegular, MenuSurface, StateDot, Tooltip, useAnchoredPosition,
IconCheckOutlineRegular, MenuSurface, StateDot, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
@@ -522,9 +522,7 @@ function CatalogDropdown(props: CatalogDropdownProps) {
const countRef = useRef<HTMLSpanElement>(null)
const activityId = useId()
const menuRef = useRef<HTMLDivElement>(null)
const menuPosition = useAnchoredPosition({
open, anchorRef: triggerRef, panelRef: menuRef, gap: 5, margin: 16,
})
const anchorName = `--dsh-subagents-${activityId.replaceAll(':', '')}`
const hoverOpenTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// A click-opened (pinned) menu ignores hover-out; only explicit dismissal closes it.
@@ -708,6 +706,7 @@ function CatalogDropdown(props: CatalogDropdownProps) {
>
<button
ref={triggerRef}
style={{ anchorName }}
onPointerEnter={(event) => {
if (event.pointerType !== 'touch') scheduleHoverOpen()
}}
@@ -767,7 +766,7 @@ function CatalogDropdown(props: CatalogDropdownProps) {
<MenuSurface
ref={menuRef}
className={css.menu}
style={menuPosition ?? { visibility: 'hidden' }}
style={{ positionAnchor: anchorName }}
role="dialog"
tabIndex={-1}
aria-label={t('tree.aria')}
@@ -782,12 +782,12 @@ describe('SubagentHeaderLineage', () => {
expect(screen.queryByRole('tree')).toBeNull()
await advance(1)
const menu = screen.getByRole('dialog')
expect(menu.style.top).toBe('45px')
expect(menu.style.left).toBe('50px')
expect(menu.style.positionAnchor).toBe(trigger.style.anchorName)
expect(trigger.style.anchorName).not.toBe('')
triggerRect.mockReturnValue({ bottom: 60, left: 70 } as DOMRect)
fireEvent.resize(window)
expect(menu.style.top).toBe('65px')
expect(menu.style.left).toBe('70px')
expect(menu.style.positionAnchor).toBe(trigger.style.anchorName)
expect(triggerRect).not.toHaveBeenCalled()
fireEvent.pointerLeave(trigger.parentElement!)
fireEvent.pointerEnter(menu)
await advance(120)
@@ -870,50 +870,42 @@ describe('SubagentHeaderLineage', () => {
expect(screen.getByRole('tree')).toBeTruthy()
})
it('repositions an open catalog after viewport resize and document scroll', () => {
it('keeps the open catalog linked to its CSS anchor without layout reads', () => {
const view = render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /\d+ 个子智能体$/ })
const bounds = vi.spyOn(trigger, 'getBoundingClientRect')
bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect)
hoverCatalog(trigger)
const menu = screen.getByRole('dialog')
expect(menu.style.top).toBe('25px')
expect(menu.style.left).toBe('30px')
bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(menu.style.top).toBe('75px')
expect(menu.style.left).toBe('80px')
bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect)
act(() => { document.dispatchEvent(new Event('scroll')) })
expect(menu.style.top).toBe('95px')
expect(menu.style.left).toBe('100px')
expect(trigger.style.anchorName).not.toBe('')
expect(menu.style.positionAnchor).toBe(trigger.style.anchorName)
act(() => { window.dispatchEvent(new Event('resize')); document.dispatchEvent(new Event('scroll')) })
expect(menu.style.positionAnchor).toBe(trigger.style.anchorName)
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
expect(bounds).not.toHaveBeenCalled()
view.unmount()
})
it('keeps the catalog inside a resized visual viewport', () => {
it('does not shrink the catalog through visual viewport inline overrides', () => {
const viewport = Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 0, offsetTop: 0 })
vi.stubGlobal('visualViewport', viewport)
vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(336)
vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(160)
const listen = vi.spyOn(viewport, 'addEventListener')
render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /\d+ 个子智能体$/ })
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(300, 200, 70, 20))
fireEvent.click(trigger)
const menu = screen.getByRole('dialog')
expect(menu.style.left).toBe('148px')
expect(menu.style.top).toBe('224px')
const placement = menu.style.positionAnchor
act(() => {
viewport.width = 400
viewport.height = 300
viewport.dispatchEvent(new Event('resize'))
})
expect(menu.style.left).toBe('48px')
expect(menu.style.top).toBe('124px')
expect(menu.style.getPropertyValue('--dsh-anchored-max-width')).toBe('368px')
expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe('268px')
expect(menu.style.positionAnchor).toBe(placement)
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
expect(menu.style.getPropertyValue('--dsh-anchored-max-width')).toBe('')
expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe('')
expect(listen).not.toHaveBeenCalled()
})
it('cancels a pending hover when the trigger becomes hidden', async () => {
@@ -9,7 +9,7 @@
* occupant's own create-folder affordance already covers creating one.
*/
import type { ReactNode, RefObject } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button, IconFolderCloseRegular, IconPlusOutlineRegular, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -74,12 +74,10 @@ export function WorkspacePickFlow({
side = 'bottom',
selectedId,
}: WorkspacePickFlowProps) {
// Without an owner-provided anchor, the menu remains hidden.
const absentAnchor = useRef<HTMLElement>(null)
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
() => anchorRef?.current?.getBoundingClientRect() ?? null,
[anchorRef],
)
const [errorOpen, setErrorOpen] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
@@ -221,7 +219,7 @@ export function WorkspacePickFlow({
onClose={onClose}
side={side}
portal
getAnchorRect={getAnchorRect}
anchorRef={anchorRef ?? absentAnchor}
/>
{open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{renderDirectoryFlow(flowOwner)}
@@ -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 () => {
@@ -15,8 +15,8 @@
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-experimental-session-inspector/use-this-package:
en: e325541ec2c31c37
zh: 0833d5ef4b23db4a
en: 810d279db66cd92e
zh: d266eaa6971bde39
/deepseek-ai-dsh-experimental-session-inspector/understand-the-implementation:
en: 6d71dcef4dca024b
zh: 17b24af15211fede
@@ -26,7 +26,7 @@ Inspect raw Session logs and Chat structures through the optional [Developer too
The Developer tools bundle mounts this plugin as `session-inspector`. It registers the `session-inspector-log` Sidebar page for the Sidebar's current Session, without a Conversation View or header label. The selector initially shows Raw log, with the current row count alongside it. Changing presentation resets local selection, type filtering, and scroll state. This plugin requires no debugging argument, accepts no configuration, and changes no Session data.
Click the filter icon in the Type header to enter a type fragment. The editor uses `useAnchoredPosition` to follow its header button through browser zoom, scrolling, and resizing, and focuses the input after positioning. Suggestions update asynchronously from all loaded records. Enter or **Apply filter** confirms the fragment; clicking a suggestion or selecting it with arrow keys and Enter confirms that type. Matching ignores case and uses containment: `delta` and `*delta*` both match reasoning and tool-call deltas. Editing alone does not change the table. Escape, Cancel, or clicking outside discards the draft; clearing and confirming restores all types.
Click the filter icon in the Type header to enter a type fragment. CSS anchors keep the editor attached to its header button within layout-viewport size limits, and opening focuses the input. Browser zoom and panning move the editor with the page; it may extend beyond the visual viewport, including while the keyboard is open. Suggestions update asynchronously from all loaded records. Enter or **Apply filter** confirms the fragment; clicking a suggestion or selecting it with arrow keys and Enter confirms that type. Matching ignores case and uses containment: `delta` and `*delta*` both match reasoning and tool-call deltas. Editing alone does not change the table. Escape, Cancel, or clicking outside discards the draft; clearing and confirming restores all types.
Filtered tables retain matching rows and their ancestor context, initially expanding folded ancestors. Context-only types appear muted and do not count toward the matching-row total. New and paginated rows use the confirmed filter. Confirming resets selection, disclosure choices, and reserved trailing space, and returns to the top. Following an object reference or picking a Chat element clears the filter if its target row is excluded, then reveals that row.
@@ -26,7 +26,7 @@ kind: "package-reference"
开发者工具组合包以 `session-inspector` 配置行挂载本插件。它为 Sidebar 当前 Session 注册 `session-inspector-log` 页面,不注册 Conversation View 或 header 标签。下拉框初始展示会话日志,旁边保留当前行数。切换展示形式会重置局部选中、类型筛选和滚动状态。本插件不需要调试参数,不接受配置,也不改变 Session 数据。
点击类型列表头的筛选图标,输入类型片段。编辑浮层通过 `useAnchoredPosition`,在浏览器缩放、滚动和尺寸变化时跟随表头按钮,并在定位后聚焦输入框。候选从所有已加载记录中异步更新。Enter 或**确认筛选**确认输入的片段;点击候选,或用方向键选中候选后按 Enter,则确认该类型。匹配忽略大小写,默认包含匹配:`delta` 和 `*delta*` 都能匹配 reasoning 与 tool-call delta。仅编辑不会改变表格。Esc、取消或点击外部会放弃草稿;清空后确认恢复全部类型。
点击类型列表头的筛选图标,输入类型片段。编辑浮层通过 CSS 锚点跟随表头按钮,并按布局视口限制尺寸,打开时聚焦输入框。浏览器缩放和平移时编辑浮层随页面移动,可以超出视觉视口,包括键盘打开时。候选从所有已加载记录中异步更新。Enter 或**确认筛选**确认输入的片段;点击候选,或用方向键选中候选后按 Enter,则确认该类型。匹配忽略大小写,默认包含匹配:`delta` 和 `*delta*` 都能匹配 reasoning 与 tool-call delta。仅编辑不会改变表格。Esc、取消或点击外部会放弃草稿;清空后确认恢复全部类型。
筛选后的表格保留匹配行及其父级上下文,初始展开折叠父级。仅作上下文的类型使用淡色,不计入匹配行数。新增和分页加载的行沿用已确认的筛选。确认后重置选中、手动折叠和尾部预留空白,并回到顶部。沿对象引用或从 Chat 选取元素时,若目标行被排除,会先清除筛选再定位该行。
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MenuSurface, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives'
import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './inspector.module.css'
@@ -28,18 +28,17 @@ export function TypeFilter({ value, suggest, apply, t }: InspectorTypeFilterProp
const input = useRef<HTMLInputElement>(null)
const id = useId()
const listId = `${id}-types`
const position = useAnchoredPosition({ open, anchorRef: anchor, panelRef: panel, gap: 4, margin: 12 })
const anchorName = `--dsh-type-filter-${id.replaceAll(':', '')}`
const close = useCallback((restoreFocus: boolean) => {
setOpen(false)
if (restoreFocus) anchor.current?.focus({ preventScroll: true })
}, [])
const commit = (query: string): void => { apply(query); close(true) }
const positioned = position !== null
useEffect(() => {
if (!open || !positioned) return
if (!open) return
input.current?.focus({ preventScroll: true })
input.current?.select()
}, [open, positioned])
}, [open])
useEffect(() => {
if (!open) return
const outside = (event: PointerEvent): void => {
@@ -73,7 +72,7 @@ export function TypeFilter({ value, suggest, apply, t }: InspectorTypeFilterProp
if (selected >= 0) panel.current?.querySelectorAll<HTMLElement>('[role="option"]')[selected]?.scrollIntoView({ block: 'nearest' })
}, [selected])
return <>
<button ref={anchor} type="button" className={css.typeFilterButton} aria-label={t('filter.title')}
<button ref={anchor} style={{ anchorName }} type="button" className={css.typeFilterButton} aria-label={t('filter.title')}
title={value === '' ? t('filter.title') : `${t('filter.title')}: *${value}*`}
aria-haspopup="dialog" aria-expanded={open} aria-pressed={value !== ''}
onClick={() => {
@@ -85,7 +84,7 @@ export function TypeFilter({ value, suggest, apply, t }: InspectorTypeFilterProp
</svg>
</button>
{open && createPortal(<MenuSurface compact ref={panel} className={css.typeFilterPanel} role="dialog" aria-label={t('filter.title')}
style={position ?? { left: 0, top: 0, visibility: 'hidden' }}
style={{ positionAnchor: anchorName }}
onKeyDown={(event) => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true) }
}}
@@ -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), 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: 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; }
@@ -112,3 +130,22 @@
@media (prefers-reduced-motion: reduce) {
.table tr { animation: none; }
}
@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;
}