diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 4d797fe340..78d959375b 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -330,27 +330,46 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) }) - it('keeps context-menu rows reachable after browser zoom', async () => { + it('keeps context-menu layout size during browser zoom and scrolls overflowing rows', async () => { const cdp = await page.context().newCDPSession(page) try { await page.setViewportSize({ width: 1291, height: 480 }) await page.locator('[data-row-key="session:context-menu-first"]').click({ button: 'right' }) const menu = page.getByRole('menu') await menu.waitFor() - // Desktop Chromium caps page scale at three; the short window makes the rows overflow at that scale. + const unzoomed = await menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(3) - await expect.poll(() => menu.evaluate((node) => { + await expect.poll(() => menu.evaluate((node, before) => { + const rect = node.getBoundingClientRect() + return Math.max(Math.abs(rect.x - before.x), Math.abs(rect.y - before.y), + Math.abs(rect.width - before.width), Math.abs(rect.height - before.height)) + }, unzoomed)).toBeLessThan(1) + expect(await menu.evaluate((node) => { const view = window.visualViewport! - const box = node.getBoundingClientRect() - return Math.min(box.left - view.offsetLeft, box.top - view.offsetTop, - view.offsetLeft + view.width - box.right, view.offsetTop + view.height - box.bottom) - })).toBeGreaterThanOrEqual(11.5) + const rect = node.getBoundingClientRect() + return rect.width > view.width || rect.height > view.height + })).toBe(true) + + // A smaller layout viewport, rather than page zoom alone, makes this list overflow. + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) + await page.setViewportSize({ width: 1291, height: 180 }) const rows = menu.locator('[role="presentation"]').first() + await expect.poll(() => rows.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true) + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) expect(await rows.evaluate((node) => { node.scrollTop = node.scrollHeight return node.scrollTop > 0 })).toBe(true) + await expect.poll(() => rows.evaluate((node) => { + const last = [...node.querySelectorAll('[role="menuitem"]')].at(-1)! + const row = last.getBoundingClientRect() + const viewport = node.getBoundingClientRect() + return row.top >= viewport.top - 1 && row.bottom <= viewport.bottom + 1 + })).toBe(true) } finally { await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await cdp.detach() diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index 51ddce2312..35b663ff74 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium, webkit } from 'playwright' import { describe, expect, it, onTestFinished } from 'vitest' import { assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, @@ -70,6 +70,30 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { } }) + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'opens the default Workspace chooser from its external trigger ($name)', async ({ engine }) => { + const scaffold = await launchWebScaffold({ firstUse: true }) + onTestFinished(() => scaffold.close()) + const browser = await engine.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, locale: 'en-US', timezoneId: 'Asia/Shanghai', + isMobile: true, hasTouch: true, + }) + await page.goto(scaffold.authenticatedUrl) + const trigger = page.getByRole('button', { name: 'Choose workspace', exact: true }) + await expect.poll(() => trigger.textContent()).toBe('Default workspace') + await trigger.tap() + const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Default workspace', exact: true }) }) + await menu.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await trigger.evaluate(node => node.style.anchorName)).toBe(await menu.evaluate(node => node.style.positionAnchor)) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'hidden' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + }, + ) + it('reports a startup directory conflict and opens the composed folder picker for recovery', async () => { const scaffold = await launchWebScaffold({ firstUse: true }) onTestFinished(() => scaffold.close()) diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index ca2f53e227..3a4eda2f70 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -47,13 +47,13 @@ function placement(page: Page) { [0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint( left + (right - left) * x, top + (bottom - top) * y, )))) - const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12) - const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12) + const verticalGap = Math.min(Math.abs(box.top - trigger.bottom - 5), Math.abs(trigger.top - box.bottom - 5)) + const horizontalAlignment = Math.min(Math.abs(box.left - trigger.left), Math.abs(box.right - trigger.right)) return { dockMode: dock.dataset.sidebarRightPanel, bodyMounted: menu.parentElement === document.body, fixed: getComputedStyle(menu).position === 'fixed', - anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1, + anchored: verticalGap <= 1 && horizontalAlignment <= 1, viewportBounded: box.left >= 11 && box.top >= 11 && box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11, overlapsBrowser, diff --git a/apps/web/tests/menu-position.e2e.ts b/apps/web/tests/menu-position.e2e.ts new file mode 100644 index 0000000000..6105de905a --- /dev/null +++ b/apps/web/tests/menu-position.e2e.ts @@ -0,0 +1,64 @@ +/** CSS menu candidates preserve layout-window margins through anchor alignment and flipping. */ +import { readFile } from 'node:fs/promises' +import { chromium, webkit, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' + +const cases = [ + { name: 'end alignment near the left edge', side: 'bottom', align: 'end', x: 232, y: 100, width: 255, height: 100, left: 12, top: 132, clearance: 12 }, + { name: 'start alignment near the right edge', side: 'bottom', align: 'start', x: 570, y: 100, width: 255, height: 100, left: 333, top: 132, clearance: 12 }, + { name: 'above placement near the desktop titlebar', side: 'top', align: 'start', x: 120, y: 110, width: 160, height: 80, left: 120, top: 142, clearance: 60 }, + { name: 'below placement near the bottom edge', side: 'bottom', align: 'start', x: 120, y: 330, width: 160, height: 100, left: 120, top: 226, clearance: 12 }, + { name: 'right placement near the right edge', side: 'right', align: 'start', x: 540, y: 100, width: 160, height: 100, left: 376, top: 100, clearance: 12 }, + { name: 'window fallback when neither vertical side fits', side: 'bottom', align: 'start', x: 120, y: 180, width: 300, height: 320, left: 12, top: 60, clearance: 60 }, +] as const + +type Placement = (typeof cases)[number] + +async function showMenu(page: Page, css: string, placement: Placement): Promise { + await page.setContent(` + + `) +} + +describe.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])('menu layout margins ($name)', ({ engine }) => { + let browser: Browser | undefined + let css: string + beforeAll(async () => { + css = await readFile(new URL('../../../packages/client/ui-primitives/src/Menu.module.css', import.meta.url), 'utf8') + browser = await engine.launch() + }) + afterAll(async () => { await browser?.close() }) + + it.each(cases)('$name', async (placement) => { + const page = await browser!.newPage({ viewport: { width: 600, height: 400 } }) + onTestFinished(() => page.close()) + await showMenu(page, css, placement) + await expect.poll(async () => { + const rect = await page.getByRole('menu').boundingBox() + if (rect === null) return null + return { + left: Math.round(rect.x), top: Math.round(rect.y), + bounded: rect.x >= 12 && rect.x + rect.width <= 588 + && rect.y >= placement.clearance && rect.y + rect.height <= 388, + } + }).toEqual({ left: placement.left, top: placement.top, bounded: true }) + }) + + it.runIf(engine === chromium)('keeps layout bounds while zoom allows visual-viewport overflow', async () => { + const page = await browser!.newPage({ viewport: { width: 600, height: 400 } }) + onTestFinished(() => page.close()) + await showMenu(page, css, cases[0]) + const menu = page.getByRole('menu') + const before = await menu.boundingBox() + const cdp = await page.context().newCDPSession(page) + try { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 }) + await expect.poll(() => page.evaluate(() => visualViewport!.scale)).toBe(3) + await expect.poll(() => menu.boundingBox()).toEqual(before) + expect(await menu.evaluate(node => node.getBoundingClientRect().width > visualViewport!.width)).toBe(true) + } finally { + await cdp.detach() + } + }) +}) diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts index 3ec0b7dd45..7bd5720bc5 100644 --- a/apps/web/tests/model-menu-observer.e2e.ts +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -25,7 +25,7 @@ const PROVIDERS = [ ? { id: 'origin-large', name: 'Origin Large', reasoningEfforts: { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max', } } - : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}${index === 6 ? ' with a long name that exceeds the model card width' : ''}` }), })) const EXPECTED_GROUPS = [ { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, @@ -289,23 +289,33 @@ describe.skipIf(webSnapshotMode() === 'record').each([ expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) expect(tripwire.pageErrors).toEqual([]) }) - it.runIf(engine === chromium)('keeps the complete model card inside the zoomed visual viewport', async () => { + it.runIf(engine === chromium)('keeps the model card at layout size when zooming beyond the visual viewport', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-zoom')) const cdp = await page.context().newCDPSession(page) try { await page.setViewportSize({ width: 402, height: 874 }) await page.getByRole('button', { name: /^选择模型/ }).click() await page.getByRole('menuitem', { name: /^模型/ }).click() - await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + const panel = page.getByRole('group', { name: '模型与推理等级', exact: true }) for (const height of [874, 500]) { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await page.setViewportSize({ width: 402, height }) - await expect.poll(() => page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((panel) => { - const viewport = window.visualViewport! - const rect = panel.getBoundingClientRect() - return Math.min(rect.left - viewport.offsetLeft, rect.top - viewport.offsetTop, - viewport.offsetLeft + viewport.width - rect.right, viewport.offsetTop + viewport.height - rect.bottom) - })).toBeGreaterThanOrEqual(11.5) - expect(await page.evaluate(() => window.visualViewport!.scale)).toBeGreaterThan(2) + await frames(page) + const unzoomed = await panel.evaluate((node) => { + const rect = node.getBoundingClientRect() + const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect() + return { width: rect.width, height: rect.height, rightGap: rect.right - anchor.right, + bottomGap: anchor.top - rect.bottom } + }) + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5) + await expect.poll(() => panel.evaluate((node, before) => { + const rect = node.getBoundingClientRect() + const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect() + return Math.max(Math.abs(rect.width - before.width), Math.abs(rect.height - before.height), + Math.abs(rect.right - anchor.right - before.rightGap), Math.abs(anchor.top - rect.bottom - before.bottomGap)) + }, unzoomed)).toBeLessThan(1) + expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true) const models = page.getByRole('menu', { name: '模型', exact: true }) const names = models.locator('[data-model-id]').locator('..') expect(await names.evaluateAll(labels => labels.every(label => @@ -388,36 +398,68 @@ describe.skipIf(webSnapshotMode() === 'record').each([ } }) - it.runIf(engine === chromium)('scrolls to and selects the last reasoning effort after zooming', async () => { + it.runIf(engine === chromium)('keeps reasoning efforts selectable after zooming with and without list overflow', async () => { const cdp = await page.context().newCDPSession(page) try { - await page.setViewportSize({ width: 402, height: 500 }) - await page.getByRole('button', { name: /^选择模型/ }).click() - await page.getByRole('menuitem', { name: /^推理等级/ }).click() - await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) - const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true }) - const last = menu.getByRole('menuitemradio').last() - expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7) - await expect.poll(() => last.evaluate((row) => { - const scroller = row.parentElement! - scroller.scrollTop = scroller.scrollHeight - return { overflow: getComputedStyle(scroller).overflowY, moved: scroller.scrollTop > 0 } - })).toEqual({ overflow: 'auto', moved: true }) - await expect.poll(() => last.evaluate((row) => { - const rect = row.getBoundingClientRect() - const viewport = row.parentElement!.getBoundingClientRect() - return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1 - })).toBe(true) - // Dispatch the touch at the now-visible row; locator.click would auto-scroll even hidden overflow. - const point = await last.evaluate((row) => { - const rect = row.getBoundingClientRect() - const viewport = window.visualViewport! - return { x: rect.left + rect.width / 2 - viewport.offsetLeft, - y: rect.top + rect.height / 2 - viewport.offsetTop } - }) - await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) - await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' }) + for (const { height, scrolls } of [{ height: 500, scrolls: false }, { height: 350, scrolls: true }]) { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) + await page.setViewportSize({ width: 402, height }) + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^推理等级/ }).click() + const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true }) + await menu.getByRole('menuitemradio').first().click() + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().reasoningEffort).toBeUndefined() + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^推理等级/ }).click() + const last = menu.getByRole('menuitemradio').last() + await expect.poll(() => last.getAttribute('aria-checked')).toBe('false') + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 }) + await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5) + expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7) + await expect.poll(() => last.evaluate((row) => { + const scroller = row.parentElement! + scroller.scrollTop = scroller.scrollHeight + return { overflow: getComputedStyle(scroller).overflowY, + overflows: scroller.scrollHeight > scroller.clientHeight, moved: scroller.scrollTop > 0 } + })).toEqual({ overflow: 'auto', overflows: scrolls, moved: scrolls }) + await expect.poll(() => last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = row.parentElement!.getBoundingClientRect() + return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1 + })).toBe(true) + const target = await last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + return { + left: Math.max(0, Math.min(window.innerWidth - viewport.width, rect.left + rect.width / 2 - viewport.width / 2)), + top: Math.max(0, Math.min(window.innerHeight - viewport.height, rect.top + rect.height / 2 - viewport.height / 2)), + offsetLeft: viewport.offsetLeft, offsetTop: viewport.offsetTop, + } + }) + // Pan from the page margin so wheel input cannot scroll the list instead. + await cdp.send('Input.synthesizeScrollGesture', { + x: 1, y: 1, xDistance: target.offsetLeft - target.left, yDistance: target.offsetTop - target.top, + gestureSourceType: 'mouse', speed: 800, + }) + await expect.poll(() => last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + const x = rect.left + rect.width / 2 - viewport.offsetLeft + const y = rect.top + rect.height / 2 - viewport.offsetTop + return x >= 0 && x < viewport.width && y >= 0 && y < viewport.height + })).toBe(true) + // Dispatch touch without locator auto-scrolling the last row into view. + const point = await last.evaluate((row) => { + const rect = row.getBoundingClientRect() + const viewport = window.visualViewport! + return { x: rect.left + rect.width / 2 - viewport.offsetLeft, + y: rect.top + rect.height / 2 - viewport.offsetTop } + }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' }) + await menu.waitFor({ state: 'detached' }) + } } finally { await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 }) await cdp.detach() diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 34cbaf4155..2b39f5e41e 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -920,6 +920,43 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps a wide schedule catalog below its header trigger in a narrow viewport', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-anchor')) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'Check exact cadence', exact: true }).click() + await manager.getByRole('button', { name: /^Linked session.*: open linked session$/ }).click() + await liveAgent(scaffold, CATALOG_SESSION_ID) + const collapse = page.getByRole('button', { name: 'Collapse sidebar', exact: true }) + if (await collapse.isVisible()) await collapse.click() + await page.setViewportSize({ width: 360, height: 586 }) + await page.evaluate(() => document.fonts.ready) + const trigger = page.getByRole('button', { name: '3 automation tasks', exact: true }) + await trigger.click() + const catalog = page.getByRole('list', { name: 'Enabled automation tasks', exact: true }) + await expect.poll(() => catalog.evaluate((node) => { + const anchor = document.querySelector('button[aria-label="3 automation tasks"]')!.getBoundingClientRect() + const rect = node.getBoundingClientRect() + return { + gap: Math.round(rect.top - anchor.bottom), + inside: rect.left >= 15.5 && rect.right <= innerWidth - 15.5 + && rect.top >= 15.5 && rect.bottom <= innerHeight - 15.5, + widerThanEitherSide: rect.width > innerWidth - 16 - anchor.left && rect.width > anchor.right - 16, + } + })).toEqual({ gap: 5, inside: true, widerThanEitherSide: true }) + expect(await catalog.getByRole('listitem').count()).toBe(3) + await page.keyboard.press('Escape') + await catalog.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(node => node === document.activeElement)).toBe(true) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 900, height: 900 }) + const expand = page.getByRole('button', { name: 'Open sidebar', exact: true }) + if (await expand.isVisible()) await expand.click() + } + }) + it('reads stored tasks without a live Session and deletes them through the catalog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog')) const layers = [ diff --git a/apps/web/tests/schedule-pickers.e2e.ts b/apps/web/tests/schedule-pickers.e2e.ts index 2548f71777..cb0531c9da 100644 --- a/apps/web/tests/schedule-pickers.e2e.ts +++ b/apps/web/tests/schedule-pickers.e2e.ts @@ -1,5 +1,5 @@ /** Zoomed automation pickers truncate wide labels and keep vertical choices reachable. */ -import type { Browser, Locator, Page } from 'playwright' +import type { Browser, CDPSession, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-schedule' @@ -12,7 +12,7 @@ let browser: Browser beforeAll(async () => { scaffold = await launchWebScaffold() - // Two digits must overflow at 500% zoom; narrower system UI fonts can fit. + // Long zone labels use the same font on every host. await scaffold.ctx.settings.update('ui-theme', { textFontFamily: 'monospace' }) await scaffold.ctx.schedule.create({ title: TITLE, prompt: 'Inspect the deployment log', @@ -30,30 +30,87 @@ afterAll(async () => { } }) -/** Assert that the complete floating card stays inside the zoomed visual viewport. */ -async function expectContained(panel: Locator): Promise { - await expect.poll(() => panel.evaluate((node) => { - const view = window.visualViewport! +/** Pan the visual viewport to a row, then touch it without locator auto-scroll. */ +async function touchRow(cdp: CDPSession, row: Locator): Promise { + const target = await row.evaluate((node) => { const rect = node.getBoundingClientRect() - return Math.min(rect.left - view.offsetLeft, rect.top - view.offsetTop, - view.offsetLeft + view.width - rect.right, view.offsetTop + view.height - rect.bottom) - })).toBeGreaterThanOrEqual(11.5) + const view = window.visualViewport! + return { + left: Math.max(0, Math.min(window.innerWidth - view.width, rect.left + rect.width / 2 - view.width / 2)), + top: Math.max(0, Math.min(window.innerHeight - view.height, rect.top + rect.height / 2 - view.height / 2)), + offsetLeft: view.offsetLeft, offsetTop: view.offsetTop, + } + }) + await cdp.send('Input.synthesizeScrollGesture', { + x: 1, y: 1, xDistance: target.offsetLeft - target.left, yDistance: target.offsetTop - target.top, + gestureSourceType: 'mouse', speed: 800, + }) + await expect.poll(() => row.evaluate((node) => { + const rect = node.getBoundingClientRect() + const view = window.visualViewport! + const x = rect.left + rect.width / 2 - view.offsetLeft + const y = rect.top + rect.height / 2 - view.offsetTop + return x >= 0 && x < view.width && y >= 0 && y < view.height + })).toBe(true) + const point = await row.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { x: rect.left + rect.width / 2 - window.visualViewport!.offsetLeft, + y: rect.top + rect.height / 2 - window.visualViewport!.offsetTop } + }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } -/** Narrow labels ellipsize instead of increasing their row's width. */ -async function expectEllipsizedText(labels: Locator): Promise { +/** Labels retain ellipsis styling without increasing the list's width. */ +async function expectEllipsisStyle(labels: Locator): Promise { expect(await labels.evaluateAll(nodes => nodes.every((node) => { const style = getComputedStyle(node) return style.textOverflow === 'ellipsis' && style.overflowX === 'hidden' }))).toBe(true) - expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true) } describe.each([ - { locale: 'en-US', tasks: 'Automation tasks', time: 'Time', second: 'Second', zone: 'Time zone' }, - { locale: 'zh-CN', tasks: '自动化任务', time: '时间', second: '秒', zone: '时区' }, + { locale: 'en-US', tasks: 'Automation tasks', time: 'Time', second: 'Second', zone: 'Time zone', repeat: 'Repeat' }, + { locale: 'zh-CN', tasks: '自动化任务', time: '时间', second: '秒', zone: '时区', repeat: '重复' }, ])('automation pickers ($locale)', (copy) => { - it.each(['clock', 'zone'] as const)('ellipsizes the %s labels and keeps vertical choices reachable at 500% zoom', async (kind) => { + it('keeps the repeat menu inside the short layout viewport before and after zoom', async () => { + const context = await browser.newContext({ + viewport: { width: 360, height: 586 }, locale: copy.locale, timezoneId: 'Asia/Shanghai', + isMobile: true, hasTouch: true, + }) + const page = await context.newPage() + try { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await revealNavigationDrawer(page, copy.locale === 'zh-CN' ? 'zh' : 'en') + await page.getByRole('button', { name: copy.tasks, exact: true }).click() + const detail = page.getByTestId('task-manager-page') + await detail.getByRole('button', { name: TITLE, exact: true }).click() + await page.evaluate(() => document.fonts.ready) + await detail.getByRole('button', { name: new RegExp(`^${copy.repeat}`) }).click() + const menu = page.getByRole('menu') + await expect.poll(() => menu.evaluate((node) => { + const rect = node.getBoundingClientRect() + return Math.min(rect.left, rect.top, innerWidth - rect.right, innerHeight - rect.bottom) + })).toBeGreaterThanOrEqual(11.5) + const unzoomed = await menu.boundingBox() + expect(unzoomed).not.toBeNull() + const cdp = await context.newCDPSession(page) + try { + await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 }) + await expect.poll(() => page.evaluate(() => visualViewport!.scale)).toBe(5) + await expect.poll(() => menu.boundingBox()).toEqual(unzoomed) + } finally { + await cdp.detach() + } + } catch (error) { + await saveFailureShot(page, `schedule-repeat-bounds-${copy.locale}`) + throw error + } finally { + await context.close() + } + }) + + it.each(['clock', 'zone'] as const)('keeps %s layout size, clipping and vertical choices reachable at 500% zoom', async (kind) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: copy.locale, timezoneId: 'Asia/Shanghai', isMobile: true, hasTouch: true, @@ -74,15 +131,28 @@ describe.each([ const panel = kind === 'clock' ? page.getByRole('dialog', { name: copy.time, exact: true }) : page.getByRole('menu') + const before = await panel.evaluate((node) => { + const rect = node.getBoundingClientRect() + return { width: rect.width, height: rect.height, left: rect.left, top: rect.top } + }) const cdp = await context.newCDPSession(page) await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 }) await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(5) - await expectContained(panel) + await expect.poll(() => panel.evaluate((node, original) => { + const rect = node.getBoundingClientRect() + return Math.max(Math.abs(rect.width - original.width), Math.abs(rect.height - original.height), + Math.abs(rect.left - original.left), Math.abs(rect.top - original.top)) + }, before)).toBeLessThan(1) + expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true) expect(await panel.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) if (kind === 'clock') { - await expectEllipsizedText(panel.locator('[role="option"] > span')) + await expectEllipsisStyle(panel.locator('[role="option"] > span')) const columns = panel.getByRole('listbox') + expect(await columns.evaluateAll(nodes => nodes.every((node) => { + const style = getComputedStyle(node) + return style.overscrollBehaviorX === 'auto' && style.overscrollBehaviorY === 'contain' + }))).toBe(true) expect(await columns.evaluateAll(nodes => nodes.every(node => node.scrollWidth <= node.clientWidth))).toBe(true) const seconds = panel.getByRole('listbox', { name: copy.second, exact: true }) @@ -97,10 +167,15 @@ describe.each([ return label.left >= column.left && label.right <= column.right && label.top >= column.top && label.bottom <= column.bottom })).toBe(true) - await last.click() + await touchRow(cdp, last) await expect.poll(() => trigger.textContent()).toMatch(/:59$/) } else { - await expectEllipsizedText(panel.locator('[role="menuitem"] > span')) + const labels = panel.locator('[role="menuitem"] > span') + await expectEllipsisStyle(labels) + // Long English names exercise overflow; translated labels may fit the layout-sized menu. + if (copy.locale === 'en-US') { + expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true) + } const viewport = panel.locator('[role="presentation"]').last() expect(await viewport.evaluate((node) => { node.scrollTop = node.scrollHeight @@ -113,7 +188,7 @@ describe.each([ return row.left >= viewport.left && row.right <= viewport.right && row.top >= viewport.top && row.bottom <= viewport.bottom })).toBe(true) - await last.click() + await touchRow(cdp, last) await panel.waitFor({ state: 'hidden' }) } expect(consoleWatch.pageErrors).toEqual([]) diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml new file mode 100644 index 0000000000..3805e25d52 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md +/: + en: bdb7c51789ea8d61 + zh: 5bd9ab5abbf48670 +/css-anchored-menus: + en: 2d9b87ac50fc94bb + zh: 97f8c85f921b3793 +/css-anchored-menus/change: + en: 6361b58498cf3297 + zh: 4e1d21372bec95a9 +/css-anchored-menus/migration: + en: 7dba5d8b6e6bdc4a + zh: d97a7c13086e9a00 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md new file mode 100644 index 0000000000..bcb19d01bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Client UI consumers must replace JavaScript panel positioning and Menu rectangle callbacks with CSS anchors." +--- + +# CSS-anchored menus + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-client-ui-primitives` removes the `useAnchoredPosition` value export and `AnchoredPositionOptions` type export. `Menu` replaces `getAnchorRect` with `anchorRef` for an external trigger element or `anchorPoint` for a point-triggered menu. Custom Client UI that imports the removed exports or passes the removed prop must migrate. + +Portaled menus follow their anchors through CSS Anchor Positioning. Their size limits use the layout viewport; browser zoom and panning move them with the page, and the panels may extend beyond the visual viewport, including while the keyboard is open. Menu dismissal, keyboard navigation, and internal scrolling remain available. The browser must support the CSS anchor positioning properties used by the component. + +## Migration + +1. Remove imports of `useAnchoredPosition` and `AnchoredPositionOptions`. For a custom panel, give its trigger a unique `anchor-name` and set the panel's `position-anchor` to the same name. Define placement and size limits in CSS; see the [model selector](../../../../packages/client/ui-model-selection/README.md#understand-the-implementation). +2. When `Menu.getAnchorRect` returned a trigger's rectangle, pass the trigger's element ref as `anchorRef` instead. An inline `anchor` rendered by `Menu` needs no external ref. +3. When the callback returned a zero-size rectangle for a pointer gesture, pass `{ x, y }` as `anchorPoint` in the same client-viewport coordinates as `event.clientX`/`clientY` or `getBoundingClientRect()`. `Menu` performs the Safari-to-layout conversion when opening or changing the point; callers must not add the visual-viewport offset themselves. +4. Rebuild the custom Client UI and check that no removed import or prop remains. Verify opening, scrolling, Escape, focus restoration, and selection before and after browser zoom. Assertions must allow visual-viewport overflow while retaining layout-viewport bounds and content access. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md new file mode 100644 index 0000000000..615d38906a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/css-anchored-menus/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Client UI 使用方必须将 JavaScript 面板定位和 Menu 矩形回调替换为 CSS 锚点。" +--- + +# CSS 锚定菜单 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-primitives` 移除 `useAnchoredPosition` 值导出和 `AnchoredPositionOptions` 类型导出。`Menu` 将 `getAnchorRect` 替换为指向外部触发元素的 `anchorRef`,或用于按坐标打开菜单的 `anchorPoint`。导入已移除导出或传入已移除 prop 的自定义 Client UI 必须迁移。 + +使用 portal 的菜单通过 CSS Anchor Positioning 跟随锚点。尺寸限制采用布局视口;浏览器缩放和平移时菜单随页面移动,面板可以超出视觉视口,包括键盘打开时。菜单关闭、键盘导航和内部滚动保持可用。浏览器必须支持组件使用的 CSS 锚点定位属性。 + +## 迁移 + +1. 移除 `useAnchoredPosition` 和 `AnchoredPositionOptions` 导入。自定义面板为触发元素设置唯一的 `anchor-name`,将面板的 `position-anchor` 设置为相同名称,并用 CSS 定义位置和尺寸限制;参见[模型选择器](../../../../packages/client/ui-model-selection/README.zh.md#understand-the-implementation)。 +2. 原 `Menu.getAnchorRect` 返回触发元素矩形时,改为通过 `anchorRef` 传入该元素的 ref。由 `Menu` 渲染的内联 `anchor` 无需外部 ref。 +3. 原回调为指针手势返回零尺寸矩形时,改为通过 `anchorPoint` 传入 `{ x, y }`,坐标与 `event.clientX`/`clientY` 或 `getBoundingClientRect()` 使用相同的客户端视口坐标系。`Menu` 在打开或坐标变化时执行 Safari 到布局视口的转换;调用方不要自行叠加视觉视口偏移。 +4. 重新构建自定义 Client UI,确认不再使用已移除的导入或 prop。在浏览器缩放前后验证打开、滚动、Escape、焦点恢复和选择。断言应允许超出视觉视口,同时保留布局视口约束及内容访问能力。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 54168bf257..45b056511e 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: c5b23d0ed29a0425 zh: 689bab2c8253294a /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: cfdfe3d99d3f4e09 - zh: 9330bad3d3f7a689 + en: d363057b5c0b2571 + zh: 0f6fe864d9673576 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: 382821ca3ffa60dd zh: 9840ffa07c13082a diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b23b9dc3e1..e62a2645e8 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -78,7 +78,7 @@ A completed Turn shows an expandable usage row only when the loaded window inclu Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. -The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. +The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. Statistics dialogs use CSS anchors and layout-viewport size limits; browser zoom and panning move them with the page, so they may extend beyond the visual viewport. Taller dialog content scrolls internally. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index e5f8add05b..54d7f34def 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -78,7 +78,7 @@ Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。统计对话框通过 CSS 锚点定位,并按布局视口限制尺寸;浏览器缩放和平移时随页面移动,因此可以超出视觉视口。较高的对话框内容可在内部滚动。 明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 7108c44719..dd5060ff5c 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -17,7 +17,7 @@ import type { ChatSnapshot } from '../contract/snapshot.ts' import { formatTokensPerSecond } from './message-chrome.ts' import { assistantStepReading } from '../contract/turn-metrics.ts' import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' -import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts' +import { useStatDialog } from './stat-dialog.ts' import css from './StatsPills.module.css' import dialogCss from './stat-dialog.module.css' @@ -203,9 +203,9 @@ function DialogPill({ stat, icon, label, text, title, titleValue, children }: Pi titleValue?: string children: ReactNode }) { - const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() + const { open, setOpen, rootRef, panelRef, anchorName } = useStatDialog() return ( - + {/* The open dialog already shows every figure; a tooltip would cover it. */} + {}} />) const menu = screen.getByRole('menu') - // side=bottom, align=start: below the host-supplied rect, left-aligned. - expect(menu.style.left).toBe('40px') - expect(menu.style.top).toBe('132px') + expect(menu.style.positionAnchor).not.toBe('') + expect(menu.style.positionAnchor).toBe(anchor.current?.style.anchorName) + expect(menu.style.positionAnchor).not.toBe('--existing-anchor') + expect(menu.style.left).toBe('') + expect(menu.style.top).toBe('') + view.rerender(<> + {}} />) + expect(anchor.current?.style.anchorName).toBe('--existing-anchor') }) - it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => { - render( - null} - anchor={null} - items={items} - onSelect={() => {}} - onClose={() => {}} - />) + it('waits for an external anchor before displaying the portal menu', () => { + const anchor = createRef() + render( {}} />) expect(screen.queryByRole('menu')).toBeNull() }) - it('shows and focuses a portal menu when its host anchor becomes available', () => { - vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }) - let rect: DOMRect | null = null + it('focuses once an external anchor mounts and preserves row focus while it moves', () => { + const anchor = createRef() + function Fixture({ present, margin = 0 }: { present: boolean; margin?: number }) { + return <>{present && } + {}} /> + } + const geometry = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') try { - render( rect} anchor={null} - items={[...items, { id: 'c', label: 'Gamma' }]} onClose={() => {}} />) + const view = render() expect(screen.queryByRole('menu')).toBeNull() - rect = new DOMRect(40, 100, 32, 28) - act(() => { vi.advanceTimersToNextFrame() }) - expect(screen.getByRole('menu').style.left).toBe('40px') + view.rerender() + const name = screen.getByRole('menu').style.positionAnchor + expect(name).toBe(anchor.current?.style.anchorName) expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' })) screen.getByRole('menuitem', { name: 'Gamma' }).focus() - rect = new DOMRect(80, 100, 32, 28) - act(() => { vi.advanceTimersToNextFrame() }) - expect(screen.getByRole('menu').style.left).toBe('80px') + view.rerender() + expect(screen.getByRole('menu').style.positionAnchor).toBe(name) expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Gamma' })) - } finally { - cleanup() - vi.useRealTimers() - } + expect(geometry).not.toHaveBeenCalled() + } finally { geometry.mockRestore() } }) - it('keeps a portal menu aligned through WebKit zoom and visual-viewport panning', () => { + it('uses a replacement external ref for inside clicks, keyboard entry and focus return', () => { + const first = createRef() + const second = createRef() + const onClose = vi.fn() + function Fixture({ useSecond }: { useSecond: boolean }) { + return <> + + } + const view = render() + view.rerender() + fireEvent.pointerDown(second.current!) + expect(onClose).not.toHaveBeenCalled() + fireEvent.pointerDown(first.current!) + expect(onClose).toHaveBeenCalledTimes(1) + second.current!.focus() + fireEvent.keyDown(second.current!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(document.activeElement).toBe(second.current) + }) + + it('leaves browser zoom and viewport panning to CSS without geometry reads or viewport listeners', () => { const viewport = Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 }) vi.stubGlobal('visualViewport', viewport) - vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) - vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) - const remove = vi.spyOn(viewport, 'removeEventListener') - const ui = render( new DOMRect(100, 200, 200, 20)} - anchor={null} items={items} onClose={() => {}} />) - const menu = screen.getByRole('menu') - expect(menu.style.left).toBe('220px') - expect(menu.style.top).toBe('284px') - act(() => { viewport.dispatchEvent(new Event('resize')) }) - expect(menu.style.left).toBe('220px') - expect(menu.style.top).toBe('284px') - act(() => { - viewport.offsetLeft = 100 - viewport.offsetTop = 120 - viewport.dispatchEvent(new Event('scroll')) - }) - expect(menu.style.left).toBe('280px') - expect(menu.style.top).toBe('344px') - act(() => { - viewport.height = 200 - viewport.dispatchEvent(new Event('resize')) - }) - expect(menu.style.top).toBe('228px') - ui.unmount() - expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['scroll', 'resize'])) + const add = vi.spyOn(viewport, 'addEventListener') + const geometry = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + try { + const view = render(Open} items={items} onClose={() => {}} />) + const menu = screen.getByRole('menu') + const name = menu.style.positionAnchor + expect(name).not.toBe('') + act(() => { + viewport.offsetLeft = 100 + viewport.offsetTop = 120 + viewport.height = 200 + viewport.dispatchEvent(new Event('scroll')) + viewport.dispatchEvent(new Event('resize')) + }) + expect(menu.style.positionAnchor).toBe(name) + expect(menu.style.left).toBe('') + expect(menu.style.top).toBe('') + expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe('') + expect(geometry).not.toHaveBeenCalled() + expect(add).not.toHaveBeenCalled() + view.unmount() + } finally { + geometry.mockRestore() + add.mockRestore() + vi.unstubAllGlobals() + } }) - it('follows an anchor moving without scroll or resize and stops tracking when closed or unmounted', () => { - vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }) - let rect = new DOMRect(40, 100, 32, 28) - const getAnchorRect = vi.fn(() => rect) - const props = { portal: true, anchor: null, items, getAnchorRect, onClose: () => {} } - try { - const view = render() - rect = new DOMRect(140, 180, 32, 28) - act(() => { vi.advanceTimersToNextFrame() }) - expect(screen.getByRole('menu').style.left).toBe('140px') - expect(screen.getByRole('menu').style.top).toBe('212px') - - view.rerender() - getAnchorRect.mockClear() - act(() => { vi.advanceTimersToNextFrame() }) - expect(getAnchorRect).not.toHaveBeenCalled() - - view.rerender() - view.unmount() - getAnchorRect.mockClear() - act(() => { vi.advanceTimersToNextFrame() }) - expect(getAnchorRect).not.toHaveBeenCalled() - } finally { - cleanup() - vi.useRealTimers() - } + it('gives separate portal menus independent CSS anchors', () => { + const view = render(<> + First} items={items} onClose={() => {}} /> + Second} items={items} onClose={() => {}} /> + ) + const menus = screen.getAllByRole('menu') + const first = screen.getByRole('button', { name: 'First' }).parentElement! + const second = screen.getByRole('button', { name: 'Second' }).parentElement! + expect(menus[0]!.style.positionAnchor).toBe(first.style.anchorName) + expect(menus[1]!.style.positionAnchor).toBe(second.style.anchorName) + expect(menus[0]!.style.positionAnchor).not.toBe(menus[1]!.style.positionAnchor) + view.unmount() }) it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => { @@ -766,7 +763,7 @@ describe('Menu', () => { // Outside the anchor wrapper subtree — overflow-clipping ancestors can't crop it. expect(container.contains(menu)).toBe(false) expect(menu.parentElement).toBe(document.body) - expect(menu.style.top).not.toBe('') + expect(menu.style.positionAnchor).not.toBe('') fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) expect(onSelect).toHaveBeenCalledWith('a') fireEvent.pointerDown(menu) @@ -780,14 +777,14 @@ describe('Menu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => { - render( - trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + it('passes top-end placement to CSS without writing measured coordinates', () => { + render(trigger} + items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') - expect(menu.style.left).not.toBe('') - expect(menu.style.top).not.toBe('') - expect(menu.style.right).toBe('') - expect(menu.style.bottom).toBe('') + expect(menu.getAttribute('data-anchor-side')).toBe('top') + expect(menu.getAttribute('data-anchor-align')).toBe('end') + expect(menu.style.positionAnchor).not.toBe('') + for (const edge of ['left', 'top', 'right', 'bottom'] as const) expect(menu.style[edge]).toBe('') }) it('renders footer rows in a pinned section below the items; they still select', () => { @@ -823,51 +820,7 @@ describe('Menu', () => { expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) }) - it('keeps the near viewport margin when the list cannot fit the visible viewport', () => { - // jsdom lays nothing out, so the list's own size and the viewport it has to - // fit inside are stubbed: this is the tall workspace list on a phone, where - // the browser chrome leaves less height than the list needs. - const height = vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(900) - const width = vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(900) - vi.stubGlobal('innerHeight', 600) - vi.stubGlobal('innerWidth', 600) - try { - render( {}} onClose={() => {}} />) - const menu = screen.getByRole('menu') - expect(menu.style.top).toBe('12px') - expect(menu.style.left).toBe('12px') - } finally { - height.mockRestore() - width.mockRestore() - vi.unstubAllGlobals() - } - }) - it('clamps inside the visual viewport where the browser reports a smaller one', () => { - // Mobile browser chrome: the visual viewport is what the user sees, while - // window.innerHeight keeps the taller layout viewport. - const height = vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) - vi.stubGlobal('innerHeight', 900) - vi.stubGlobal('innerWidth', 900) - vi.stubGlobal('visualViewport', Object.assign(new EventTarget(), { width: 500, height: 500, offsetLeft: 0, offsetTop: 0 })) - try { - render( - new DOMRect(10, 700, 40, 20)} - anchor={null} - items={items} - onSelect={() => {}} - onClose={() => {}} - />) - // 500 - 400 - 12, not the layout viewport's 900. - expect(screen.getByRole('menu').style.top).toBe('88px') - } finally { - height.mockRestore() - vi.unstubAllGlobals() - } - }) }) describe('Modal', () => { diff --git a/packages/client/ui-primitives/tests/context-menu.client.spec.tsx b/packages/client/ui-primitives/tests/context-menu.client.spec.tsx index ffef90d4dd..23ab83042d 100644 --- a/packages/client/ui-primitives/tests/context-menu.client.spec.tsx +++ b/packages/client/ui-primitives/tests/context-menu.client.spec.tsx @@ -5,16 +5,19 @@ import { afterEach, expect, it, vi } from 'vitest' import { Menu, MenuItemButton } from '../src/Menu.tsx' import { ReferenceChip } from '../src/user-text.tsx' -afterEach(() => { vi.restoreAllMocks(); cleanup(); document.documentElement.style.removeProperty('--dsh-frame-top-clearance') }) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); cleanup(); document.documentElement.style.removeProperty('--dsh-frame-top-clearance') }) it('places pointer menus at the cursor above the frame inset and focuses after portal placement', async () => { document.documentElement.style.setProperty('--dsh-frame-top-clearance', '96px') const close = vi.fn() const view = render( new DOMRect(200, 20, 0, 0)} items={[{ id: 'copy', label: 'Copy' }]} />) + anchorPoint={{ x: 200, y: 20 }} items={[{ id: 'copy', label: 'Copy' }]} />) const menu = view.getByRole('menu') - expect(menu.style.top).toBe('24px') - expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe(`${window.innerHeight - 24}px`) + const point = view.container.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() + 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() + expect(point().style.left).toBe(visualOrigin ? '240px' : '200px') + expect(point().style.top).toBe(visualOrigin ? '160px' : '100px') + view.rerender() + view.rerender() + expect(point().style.left).toBe(visualOrigin ? '280px' : '200px') + expect(point().style.top).toBe(visualOrigin ? '220px' : '100px') + view.rerender() + expect(point().style.left).toBe(visualOrigin ? '300px' : '220px') + expect(point().style.top).toBe(visualOrigin ? '250px' : '130px') + expect(subscribe).not.toHaveBeenCalled() +}) + it('groups data icons per divider, preserves component rows, and makes exit frames inert', () => { const props = { open: true, portal: true, contextual: true, anchor: null, onClose: vi.fn(), items: [{ id: 'first', label: 'First', icon: }, { id: 'second', label: 'Second' }, diff --git a/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx b/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx index 711a5f834b..df7997086c 100644 --- a/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-anchor-visibility.client.spec.tsx @@ -1,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 -beforeEach(() => { - frames = new Map() - let next = 0 - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++next, callback); return next }) - vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) -}) -afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) it.each(['inert', 'hidden'].flatMap(attribute => [false, true].map(contextual => ({ attribute, contextual }))))( - 'closes when the retained anchor becomes $attribute with contextual=$contextual', ({ attribute, contextual }) => { + 'closes when the retained anchor becomes $attribute with contextual=$contextual', async ({ attribute, contextual }) => { function Fixture() { const [open, setOpen] = useState(true) const trigger = useRef(null) @@ -27,13 +20,60 @@ it.each(['inert', 'hidden'].flatMap(attribute => [false, true].map(contextual => } const view = render() expect(screen.getByRole('menu')).toBeTruthy() - view.getByTestId('owner').setAttribute(attribute, '') - act(() => { for (const callback of [...frames.values()]) callback(0) }) + await act(async () => { view.getByTestId('owner').setAttribute(attribute, ''); await Promise.resolve() }) expect(screen.queryByRole('menu')).toBeNull() expect(view.getByTestId('owner').isConnected).toBe(true) }) -it('does not restart a contextual dismissal while its original control remains inert', () => { +it('closes when an external anchor leaves the document', async () => { + const anchor = document.createElement('button') + const anchorRef = { current: anchor } + document.body.append(anchor) + function Fixture() { + const [open, setOpen] = useState(true) + return { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + } + render() + expect(screen.getByRole('menu')).toBeTruthy() + await act(async () => { anchor.remove(); await Promise.resolve() }) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it.each(['hidden', 'inert'])('tracks %s on an external anchor that mounts after opening', async (attribute) => { + function Fixture({ ready }: { ready: boolean }) { + const [open, setOpen] = useState(true) + const anchor = useRef(null) + return <>{ready && } + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + } + const view = render() + expect(screen.queryByRole('menu')).toBeNull() + view.rerender() + expect(screen.getByRole('menu')).toBeTruthy() + await act(async () => { screen.getByRole('button').setAttribute(attribute, ''); await Promise.resolve() }) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it('retargets visibility when an open menu receives a replacement element through the same ref', async () => { + function Fixture({ generation }: { generation: number }) { + const [open, setOpen] = useState(true) + const anchor = useRef(null) + return <> + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + } + const view = render() + const original = screen.getByRole('button') + await act(async () => { view.rerender(); await Promise.resolve() }) + expect(original.isConnected).toBe(false) + expect(screen.getByRole('menu')).toBeTruthy() + await act(async () => { screen.getByRole('button').setAttribute('hidden', ''); await Promise.resolve() }) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it('does not restart a contextual dismissal while its original control remains inert', async () => { const close = vi.fn() function Fixture() { const [exiting, setExiting] = useState<'dismiss'>() @@ -44,13 +84,12 @@ it('does not restart a contextual dismissal while its original control remains i } const view = render() const menu = screen.getByRole('menu') - const top = menu.style.top - view.getByTestId('owner').setAttribute('inert', '') - act(() => { for (const callback of [...frames.values()]) callback(0) }) - act(() => { for (const callback of [...frames.values()]) callback(0) }) + const anchorName = menu.style.positionAnchor + await act(async () => { view.getByTestId('owner').setAttribute('inert', ''); await Promise.resolve() }) + await act(async () => { view.getByTestId('owner').setAttribute('hidden', ''); await Promise.resolve() }) expect(close).toHaveBeenCalledTimes(1) expect(menu.style.visibility).not.toBe('hidden') - expect(menu.style.top).toBe(top) + expect(menu.style.positionAnchor).toBe(anchorName) }) it.each([undefined, 'existing-panel'])('restores the trigger aria-controls value %s after closing', (previous) => { @@ -67,3 +106,66 @@ it.each([undefined, 'existing-panel'])('restores the trigger aria-controls value view.rerender() expect(trigger.getAttribute('aria-controls')).toBe(previous ?? null) }) + + +it('reopens after its external anchor is replaced while closed', () => { + function Fixture() { + const [open, setOpen] = useState(false) + const [generation, setGeneration] = useState(0) + const anchor = useRef(null) + return <> + + + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + expect(screen.getByRole('menuitem', { name: 'Copy' })).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + const previous = screen.getByRole('button', { name: 'Open' }) + fireEvent.click(screen.getByRole('button', { name: 'Replace anchor' })) + expect(previous.isConnected).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + expect(screen.getByRole('menuitem', { name: 'Copy' })).toBeTruthy() +}) + +it.each(['before', 'after'])('opens with an external anchor mounted %s the menu in the same commit', (order) => { + function Fixture() { + const [open, setOpen] = useState(false) + const anchor = useRef(null) + const trigger = open && + return <> + + {order === 'before' && trigger} + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + {order === 'after' && trigger} + + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + expect(screen.getByRole('menuitem', { name: 'Copy' })).toBeTruthy() +}) + +it('returns focus to the input that opened an external-anchor menu', () => { + function Fixture() { + const [open, setOpen] = useState(false) + const anchor = useRef(null) + return <> + { if (event.key === 'F2') setOpen(true) }} /> + + { setOpen(false) }} items={[{ id: 'copy', label: 'Copy' }]} /> + + } + render() + const input = screen.getByRole('textbox', { name: 'Draft' }) + act(() => { input.focus() }) + fireEvent.keyDown(input, { key: 'F2' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Copy' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(document.activeElement).toBe(input) +}) diff --git a/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx b/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx index c99ce22592..00b880b062 100644 --- a/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-anchor-width.client.spec.tsx @@ -1,48 +1,50 @@ // @vitest-environment jsdom -/** Portal menus reserve their anchor width before measuring end alignment. */ +/** Portal menus delegate minimum width and viewport limits to CSS. */ +import { useRef } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { Menu } from '../src/Menu.tsx' afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) -function measureAnchor() { - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(120, 40, 280, 22)) - vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.getAttribute('role') === 'menu' ? Math.max(120, Number.parseFloat(this.style.minWidth) || 0) : 280 - }) - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(40) -} - describe('Menu anchor minimum width', () => { - it('matches the anchor and clears its minimum when the option is removed', () => { - measureAnchor() + it('registers only the external trigger as its anchor', () => { + function Fixture() { + const trigger = useRef(null) + return <> + + } + const view = render() + const trigger = view.getByRole('button', { name: 'Tools' }) + const name = view.getByRole('menu').style.positionAnchor + expect(name).not.toBe('') + expect([...document.querySelectorAll('[style]')] + .filter(node => node.style.anchorName === name)).toEqual([trigger]) + }) + + it('enables anchor-width matching and clears it when the option is removed', () => { const props = { open: true, portal: true, align: 'end' as const, anchor: , onClose: vi.fn() } const view = render() - expect(view.getByRole('menu').style.minWidth).toBe('280px') - expect(view.getByRole('menu').style.left).toBe('120px') + const menu = view.getByRole('menu') + expect(menu.hasAttribute('data-match-anchor-width')).toBe(true) + expect(menu.style.minWidth).toBe('') + expect(menu.style.positionAnchor).not.toBe('') view.rerender() - expect(view.getByRole('menu').style.minWidth).toBe('') + expect(view.getByRole('menu').hasAttribute('data-match-anchor-width')).toBe(false) }) - it('keeps the minimum inside the viewport margins when the anchor is wider', () => { - measureAnchor() - vi.stubGlobal('innerWidth', 200) - const view = render(Tools} - items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) - expect(view.getByRole('menu').style.minWidth).toBe('176px') - expect(view.getByRole('menu').style.left).toBe('12px') - }) - - it('bounds the anchor minimum by the visual viewport after browser zoom', () => { - measureAnchor() - vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) + it('keeps matching declarative when the visual viewport is narrower than its anchor', () => { + const readRect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') vi.stubGlobal('visualViewport', Object.assign(new EventTarget(), { width: 200, height: 400, offsetLeft: 40, offsetTop: 60, })) - const view = render(Tools} + const view = render(Tools} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) - expect(view.getByRole('menu').style.minWidth).toBe('176px') - expect(view.getByRole('menu').style.left).toBe('52px') + const menu = view.getByRole('menu') + expect(menu.hasAttribute('data-match-anchor-width')).toBe(true) + expect(menu.style.minWidth).toBe('') + expect(menu.style.left).toBe('') + expect(readRect).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/tests/menu-styles.client.spec.ts b/packages/client/ui-primitives/tests/menu-styles.client.spec.ts index 61f7446205..7651ec9070 100644 --- a/packages/client/ui-primitives/tests/menu-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/menu-styles.client.spec.ts @@ -23,8 +23,8 @@ describe('Menu.module.css', () => { // The fallback first: a browser that drops `dvh` keeps the layout-viewport // cap, and mobile browser chrome can then only hide the card's far edge. expect(maxHeights()).toEqual([ - 'min(calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh))', - 'min(calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh))', + 'calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)))', + 'calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)))', ]) }) diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx deleted file mode 100644 index 6e1700e7f9..0000000000 --- a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx +++ /dev/null @@ -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(Open} 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') - }) -}) diff --git a/packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts b/packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts new file mode 100644 index 0000000000..b77368bcc3 --- /dev/null +++ b/packages/client/ui-primitives/tests/overlay-top-margin.client.spec.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +/** Overlay clearance follows the desktop frame and visible anchor position. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { overlayTopMargin } from '../src/overlay-top-margin.ts' + +const root = document.documentElement +let originalClearance: string +let originalPriority: string +let originalFullscreen: string | null + +beforeEach(() => { + originalClearance = root.style.getPropertyValue('--dsh-frame-top-clearance') + originalPriority = root.style.getPropertyPriority('--dsh-frame-top-clearance') + originalFullscreen = root.getAttribute('data-fullscreen') + root.style.removeProperty('--dsh-frame-top-clearance') + root.removeAttribute('data-fullscreen') +}) + +afterEach(() => { + root.style.setProperty('--dsh-frame-top-clearance', originalClearance, originalPriority) + if (originalFullscreen === null) root.removeAttribute('data-fullscreen') + else root.setAttribute('data-fullscreen', originalFullscreen) +}) + +describe('overlayTopMargin', () => { + it('uses the requested margin when the frame publishes no clearance', () => { + expect(overlayTopMargin(12)).toBe(12) + }) + + it('uses the frame margin when no anchor is provided', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12)).toBe(60) + }) + + it('caps clearance at a visible anchor and its requested gap', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12, { top: 10, bottom: 24 }, 4)).toBe(28) + }) + + it('does not cap clearance at an anchor above the viewport', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12, { top: -10, bottom: 24 }, 4)).toBe(60) + }) + + it('keeps the requested minimum when the anchor cap is smaller', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(12, { top: 0, bottom: 4 }, 4)).toBe(12) + }) + + it('keeps the requested minimum when it exceeds frame clearance', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + expect(overlayTopMargin(80)).toBe(80) + }) + + it('excludes native frame clearance in fullscreen', () => { + root.style.setProperty('--dsh-frame-top-clearance', '40px') + root.setAttribute('data-fullscreen', '') + expect(overlayTopMargin(12)).toBe(20) + }) +}) diff --git a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx deleted file mode 100644 index df02d5a43a..0000000000 --- a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx +++ /dev/null @@ -1,236 +0,0 @@ -// @vitest-environment jsdom -/** Synchronous fixed placement across viewport changes and anchor movement. */ -import { useRef } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render, renderHook } from '@testing-library/react' -import { useAnchoredPosition } from '../src/useAnchoredPosition.ts' - -let frames: FrameRequestCallback[] -beforeEach(() => { - frames = [] - vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => frames.push(callback))) - vi.stubGlobal('cancelAnimationFrame', vi.fn()) - vi.stubGlobal('innerWidth', 1000) - vi.stubGlobal('innerHeight', 800) - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 300, 200, 20)) - vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) - vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) -}) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -function Host({ open, ...options }: Omit[0], 'anchorRef' | 'panelRef' | 'gap' | 'margin'>) { - const anchorRef = useRef(null) - const panelRef = useRef(null) - const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...options }) - return <> - - {open &&
} - -} - -function show(props: Parameters[0]) { - return render() -} - -describe('useAnchoredPosition', () => { - it.each(['anchor', 'panel'] as const)('does not measure or subscribe without the %s element', (missing) => { - const anchor = document.createElement('button') - const measure = vi.spyOn(anchor, 'getBoundingClientRect') - const { result } = renderHook(() => useAnchoredPosition({ - open: true, - anchorRef: { current: missing === 'anchor' ? null : anchor }, - panelRef: { current: missing === 'panel' ? null : document.createElement('div') }, - gap: 4, - margin: 12, - })) - expect(result.current).toBeNull() - expect(measure).not.toHaveBeenCalled() - expect(requestAnimationFrame).not.toHaveBeenCalled() - }) - - it('does not position elements from a document without a window', () => { - const detachedDocument = document.implementation.createHTMLDocument() - const { result } = renderHook(() => useAnchoredPosition({ - open: true, - anchorRef: { current: detachedDocument.createElement('button') }, - panelRef: { current: detachedDocument.createElement('div') }, - gap: 4, - margin: 12, - })) - expect(result.current).toBeNull() - expect(requestAnimationFrame).not.toHaveBeenCalled() - }) - - it.each([ - { side: 'bottom', align: 'start', left: '100px', top: '324px' }, - { side: 'bottom', align: 'end', left: '180px', top: '324px' }, - { side: 'top', align: 'start', left: '100px', top: '216px' }, - { side: 'top', align: 'end', left: '180px', top: '216px' }, - { side: 'right', align: 'start', left: '304px', top: '300px' }, - { side: 'right', align: 'end', left: '304px', top: '300px' }, - ] as const)('places $side-$align from the matching anchor edges', ({ side, align, left, top }) => { - const ui = show({ open: true, side, align }) - expect(ui.getByTestId('panel').style.left).toBe(left) - expect(ui.getByTestId('panel').style.top).toBe(top) - }) - - it.each([ - { side: 'top', anchorY: 100, fullscreen: false, top: 60 }, - { side: 'top', anchorY: 100, fullscreen: true, top: 20 }, - { side: 'bottom', anchorY: 0, fullscreen: false, top: 14 }, - ] as const)('keeps $side placement at $top with fullscreen=$fullscreen', ({ side, anchorY, fullscreen, top }) => { - const root = document.documentElement - const previous = root.getAttribute('style') - const priorFullscreen = root.getAttribute('data-fullscreen') - try { - root.style.setProperty('--dsh-frame-top-clearance', '40px') - root.toggleAttribute('data-fullscreen', fullscreen) - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, anchorY, 200, 10)) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) - const ui = show({ open: true, side }) - expect(ui.getByTestId('panel').style.top).toBe(`${top}px`) - } finally { - if (previous === null) root.removeAttribute('style') - else root.setAttribute('style', previous) - if (priorFullscreen === null) root.removeAttribute('data-fullscreen') - else root.setAttribute('data-fullscreen', priorFullscreen) - } - }) - - it('keeps the near margins when the panel exceeds the viewport', () => { - vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(1200) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(900) - const ui = show({ open: true }) - expect(ui.getByTestId('panel').style.left).toBe('12px') - expect(ui.getByTestId('panel').style.top).toBe('12px') - }) - - it('follows WebKit visual-viewport offsets, resizing, and scrolling', () => { - const viewport = Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 }) - vi.stubGlobal('visualViewport', viewport) - const remove = vi.spyOn(viewport, 'removeEventListener') - const ui = show({ open: true, side: 'top' }) - const panel = ui.getByTestId('panel') - expect(panel.style.left).toBe('140px') - expect(panel.style.top).toBe('276px') - act(() => { - viewport.offsetLeft = 100 - viewport.offsetTop = 120 - viewport.dispatchEvent(new Event('scroll')) - }) - expect(panel.style.left).toBe('200px') - expect(panel.style.top).toBe('336px') - act(() => { - viewport.height = 200 - viewport.dispatchEvent(new Event('resize')) - }) - expect(panel.style.top).toBe('228px') - ui.rerender() - expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['scroll', 'resize'])) - }) - - it('tracks resizing and ignores observer callbacks after closing', () => { - const records: Array<{ callback: ResizeObserverCallback; observed: Element[]; disconnect: ReturnType }> = [] - vi.stubGlobal('ResizeObserver', class { - private readonly record - constructor(callback: ResizeObserverCallback) { - this.record = { callback, observed: [] as Element[], disconnect: vi.fn() } - records.push(this.record) - } - observe(element: Element) { this.record.observed.push(element) } - disconnect() { this.record.disconnect() } - }) - const ui = show({ open: true, side: 'top' }) - const observer = records[0]! - const panel = ui.getByTestId('panel') - expect(observer.observed).toEqual([ui.getByRole('button'), ui.getByTestId('panel')]) - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(160) - act(() => { observer.callback([], {} as ResizeObserver) }) - expect(ui.getByTestId('panel').style.top).toBe('136px') - ui.rerender() - expect(observer.disconnect).toHaveBeenCalledOnce() - const anchor = observer.observed[0]! - const measure = vi.spyOn(anchor, 'getBoundingClientRect') - measure.mockClear() - act(() => { observer.callback([], {} as ResizeObserver) }) - expect(measure).not.toHaveBeenCalled() - expect(panel.style.getPropertyValue('--dsh-anchored-max-width')).toBe('') - expect(panel.style.getPropertyValue('--dsh-anchored-max-height')).toBe('') - }) - - it('positions without ResizeObserver and subscribes only while open', () => { - vi.stubGlobal('ResizeObserver', undefined) - const ui = show({ open: false }) - expect(requestAnimationFrame).not.toHaveBeenCalled() - act(() => { ui.rerender() }) - expect(ui.getByTestId('panel').style.top).toBe('324px') - }) - - it('positions the first render and changed content without a microtask', () => { - const ui = show({ open: true, side: 'top', layoutKey: 'root' }) - const panel = ui.getByTestId('panel') - expect(panel.style.visibility).toBe('') - expect(panel.style.top).toBe('216px') - vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(160) - ui.rerender() - expect(panel.style.top).toBe('136px') - }) - - it('uses layout-relative client rects without adding the visual offset twice', () => { - vi.stubGlobal('CSS', { supports: () => false }) - vi.stubGlobal('visualViewport', Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 })) - const ui = show({ open: true, side: 'top' }) - const panel = ui.getByTestId('panel') - expect(panel.style.left).toBe('100px') - expect(panel.style.top).toBe('216px') - expect(panel.style.getPropertyValue('--dsh-anchored-max-width')).toBe('476px') - expect(panel.style.getPropertyValue('--dsh-anchored-max-height')).toBe('376px') - ui.unmount() - expect(panel.style.getPropertyValue('--dsh-anchored-max-width')).toBe('') - expect(panel.style.getPropertyValue('--dsh-anchored-max-height')).toBe('') - }) - - it('tracks a moving host rect, hides a missing anchor, and stops on unmount', () => { - let rect: DOMRect | null = null - const getAnchorRect = vi.fn(() => rect) - const ui = show({ open: true, getAnchorRect, minWidthFromAnchor: true }) - const panel = ui.getByTestId('panel') - expect(panel.style.visibility).toBe('hidden') - rect = new DOMRect(40, 50, 20, 10) - act(() => { frames.shift()!(0) }) - expect(panel.style.left).toBe('40px') - expect(panel.style.top).toBe('64px') - rect.x = 70 - getAnchorRect.mockClear() - act(() => { frames.shift()!(16) }) - expect(getAnchorRect).toHaveBeenCalledOnce() - expect(panel.style.left).toBe('70px') - rect.y = 80 - act(() => { frames.shift()!(32) }) - expect(panel.style.top).toBe('94px') - rect.width = 40 - act(() => { frames.shift()!(48) }) - expect(panel.style.minWidth).toBe('40px') - rect.height = 20 - act(() => { frames.shift()!(64) }) - expect(panel.style.top).toBe('104px') - const measurePanel = vi.spyOn(panel, 'offsetWidth', 'get') - measurePanel.mockClear() - act(() => { frames.shift()!(80) }) - expect(measurePanel).not.toHaveBeenCalled() - rect = null - act(() => { frames.shift()!(96) }) - expect(panel.style.visibility).toBe('hidden') - ui.unmount() - const frame = frames.shift()! - frame(112) - expect(frames).toHaveLength(0) - expect(cancelAnimationFrame).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 19b49db702..96bad805a3 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -21,8 +21,8 @@ en: 4005b800e930f581 zh: ccb9318dad7f7e8f /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: - en: 72598a09be42251d - zh: 4140b2eec82c7f1f + en: 73e3c0f06c7381e8 + zh: 4b83f0eb8da65ce1 /deepseek-ai-dsh-client-ui-schedule/further-exploration: en: 2fd968202916b197 zh: f1ab66194846562b diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index d7df5059b1..2db007cce9 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -50,7 +50,7 @@ Time-zone enumeration and localized names use the browser's embedded ICU/CLDR da ## Understand the implementation -Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-hover), including keyboard-highlighted rows. Portaled task menus and date/time pickers use `useAnchoredPosition` to follow their triggers during browser zoom, scrolling, and resizing, while preserving viewport margins and the desktop frame clearance. Task-menu labels and clock values ellipsize when the visual viewport is too narrow. Clock columns shrink within the panel and scroll vertically within its available height; task menus scroll vertically to reach every choice. The Session-header popover follows the same viewport constraints and stays hidden until its first position resolves. +Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-hover), including keyboard-highlighted rows. Portaled task menus, date/time pickers, and the Session-header popover follow their triggers through CSS anchors, with layout-viewport limits and desktop frame clearance. Browser zoom and panning move these panels with the page; they may extend beyond the visual viewport, including while the keyboard is open. Labels ellipsize when they exceed the panel width. Clock columns and task-menu choices scroll vertically within the panel height limit. Horizontal gestures over clock columns can pan the zoomed page; vertical overscroll stays within the column. Outside dismissal and keyboard navigation remain available.
Implementation details — click to expand diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 9b93c87cf4..b51f75b673 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ## 理解实现 -任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。使用 portal 的任务菜单和日期、时间选择器通过 `useAnchoredPosition`,在浏览器缩放、滚动和尺寸变化时跟随触发器,并保留视口边距和桌面标题栏避让。视觉视口过窄时,任务菜单文案和时间值以省略号截断。时间列随面板缩窄,并在可用高度内纵向滚动;任务菜单通过纵向滚动到达每个选项。会话页头弹层遵循同样的视口限制,并在首次定位完成前保持隐藏。 +任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。使用 portal 的任务菜单、日期和时间选择器以及会话页头弹层通过 CSS 锚点跟随触发器,保留布局视口尺寸限制和桌面标题栏避让。浏览器缩放和平移时,这些面板随页面移动,可以超出视觉视口,包括键盘打开时。文案超出面板宽度时以省略号截断。时间列和任务菜单选项在面板高度限制内纵向滚动。时间列上的横向手势可以平移放大后的页面,纵向滚动到边缘时不继续传递到外层。外部关闭和键盘导航保持可用。
实现内部细节 — 点击展开 diff --git a/packages/client/ui-schedule/src/client/ClockPicker.module.css b/packages/client/ui-schedule/src/client/ClockPicker.module.css index 32539a242e..31122d90c5 100644 --- a/packages/client/ui-schedule/src/client/ClockPicker.module.css +++ b/packages/client/ui-schedule/src/client/ClockPicker.module.css @@ -10,10 +10,10 @@ flex-direction: column; min-width: 0; width: 52px; - max-height: min(216px, calc(var(--dsh-anchored-max-height, 222px) - 6px)); + max-height: min(216px, calc(100dvh - 30px)); overflow-x: hidden; overflow-y: auto; - overscroll-behavior: contain; + overscroll-behavior-y: contain; } /* One clock value. The right column's rows are 13px copy, so the columns set diff --git a/packages/client/ui-schedule/src/client/ClockPicker.tsx b/packages/client/ui-schedule/src/client/ClockPicker.tsx index f6bda67861..ede975bed9 100644 --- a/packages/client/ui-schedule/src/client/ClockPicker.tsx +++ b/packages/client/ui-schedule/src/client/ClockPicker.tsx @@ -76,6 +76,8 @@ export interface ClockPickerProps { readonly open: boolean /** The row's trigger button, which anchors the panel. */ readonly anchorRef: RefObject + /** CSS anchor name assigned to the row trigger. */ + readonly anchorName: string /** The staged clock text. */ readonly value: string /** Stage one picked `HH:MM:SS` clock. */ @@ -97,7 +99,7 @@ export interface ClockPickerProps { * @param props - open state, the trigger, the staged clock, the pick and close callbacks, the seconds-column switch, and row copy. * @returns the anchored panel while open, and nothing while closed. */ -export function ClockPicker({ open, anchorRef, value, onPick, onClose, seconds = true, t }: ClockPickerProps): ReactNode { +export function ClockPicker({ open, anchorRef, anchorName, value, onPick, onClose, seconds = true, t }: ClockPickerProps): ReactNode { const columns = seconds ? COLUMNS : COLUMNS.slice(0, 2) const refs = useRef(new Map()) const valueRef = useRef(value) @@ -185,6 +187,7 @@ export function ClockPicker({ open, anchorRef, value, onPick, onClose, seconds = return + /** CSS anchor name assigned to the row trigger. */ + readonly anchorName: string /** The staged ISO date. */ readonly value: string /** Stage one picked ISO date. */ @@ -115,7 +117,7 @@ export interface DatePickerProps { * @param props - open state, the trigger, the staged date, the pick and close callbacks, and row copy. * @returns the anchored panel while open, and nothing while closed. */ -export function DatePicker({ open, anchorRef, value, onPick, onClose, t }: DatePickerProps): ReactNode { +export function DatePicker({ open, anchorRef, anchorName, value, onPick, onClose, t }: DatePickerProps): ReactNode { const refs = useRef(new Map()) const valueRef = useRef(value) valueRef.current = value @@ -174,6 +176,7 @@ export function DatePicker({ open, anchorRef, value, onPick, onClose, t }: DateP return + /** CSS anchor name assigned to the trigger. */ + readonly anchorName: string /** Accessible name of the panel. */ readonly label: string /** Layout class of this picker's contents. @@ -34,16 +33,15 @@ export interface PickerPopoverProps { * @param props - open state, the trigger, the panel name and layout class, the dismissal callback, and the contents. * @returns the panel while open, and nothing while closed. */ -export function PickerPopover({ open, anchorRef, label, className, onClose, children }: PickerPopoverProps): ReactNode { +export function PickerPopover({ open, anchorRef, anchorName, label, className, onClose, children }: PickerPopoverProps): ReactNode { const panelRef = useRef(null) useDismissOnOutsidePointer(anchorRef, open, onClose, panelRef) - const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 }) if (!open) return null return createPortal(
const SECOND_MS = 1_000 -const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Current-Session reminder catalog with durable deletion, rendered only while @@ -56,14 +54,7 @@ function SessionScheduleCatalog({ const rootRef = useRef(null) const triggerRef = useRef(null) const catalogRef = useRef(null) - const catalogPosition = useAnchoredPosition({ - open, - anchorRef: triggerRef, - panelRef: catalogRef, - side: 'bottom', - gap: 5, - margin: 16, - }) + const anchorName = `--dsh-schedule-catalog-${useId().replaceAll(':', '')}` useDismissOnOutsidePointer(rootRef, open, setOpen, catalogRef) @@ -119,6 +110,7 @@ function SessionScheduleCatalog({ const trigger = (
} {draft.frequency === 'once' &&
- - { change({ start: `${date}T${clock}` }); setDateOpen(false) }} onClose={() => { setDateOpen(false) }} t={t} />
} {draft.frequency !== 'advanced' && draft.frequency !== 'minutely' && draft.frequency !== 'hourly' &&
- - { change(draft.frequency === 'daily' ? { dailyTime: time.slice(0, 5) } : { start: `${draft.start.slice(0, 10)}T${time}` }) }} onClose={() => { setClockOpen(false) }} t={t} />
} diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 62f10d6fa7..2c63791d48 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -811,7 +811,7 @@ recent choices stay visible without letting the global list dominate the detail. */ .zoneMenu.zoneMenu { height: min(420px, calc(100dvh - 48px)); - max-height: min(420px, calc(100dvh - 48px), var(--dsh-anchored-max-height, 100dvh)); + max-height: min(420px, calc(100dvh - 48px)); } /* A stronger rule makes the unlabeled recent block read as a distinct section. */ diff --git a/packages/client/ui-schedule/src/client/TaskMenu.module.css b/packages/client/ui-schedule/src/client/TaskMenu.module.css index 2f2c0f6374..f117fa4310 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.module.css +++ b/packages/client/ui-schedule/src/client/TaskMenu.module.css @@ -31,33 +31,42 @@ top: calc(100% + 4px); left: 0; z-index: 100; - min-width: min(144px, var(--dsh-anchored-max-width, 100vw)); - max-width: min(360px, var(--dsh-anchored-max-width, 100vw)); + min-width: min(144px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); } -/* Portal mode: fixed in the viewport, coordinates supplied inline from the - * anchor rect (align resolved in JS, the in-place offset rules above don't - * apply). Portaled lists must layer above modal overlays (z 1000) — an anchor - * inside a dialog still expects its menu on top. */ +/* CSS anchors follow layout changes without constraining pinch zoom. */ .portal { position: fixed; - top: auto; - left: auto; + position-area: none; + position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-end, --dsh-schedule-task-above-end, --dsh-schedule-task-window; + top: max(12px, calc(anchor(bottom) + 4px)); + bottom: 12px; + left: max(12px, anchor(left)); + right: 12px; + margin: 0; + width: max-content; + height: fit-content; + align-self: start; + justify-self: start; z-index: 1100; } -.alignEnd { +.alignEnd:not(.portal) { left: auto; right: 0; } -/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges - * (the hook's margin) and taller content scrolls inside .viewport. The dynamic - * unit, not `vh`, for the same reason as Menu.module.css: mobile browser - * chrome shrinks the visible height below the bars-hidden one `vh` reports. */ +.portal.alignEnd { + left: 12px; + right: max(12px, anchor(right)); + justify-self: end; + position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-start, --dsh-schedule-task-above-start, --dsh-schedule-task-window; +} + +/* Tall lists scroll inside the window's layout bounds. */ .scrollable { - max-height: min(calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh)); - max-height: min(calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))), var(--dsh-anchored-max-height, 100vh)); + max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); } .viewport { @@ -195,3 +204,48 @@ .separator + .separator { display: none; } + +/* Every candidate reserves the same window insets, including flipped placements. */ +@position-try --dsh-schedule-task-above { + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + bottom: max(12px, calc(anchor(top) + 4px)); + align-self: end; +} + +@position-try --dsh-schedule-task-end { + left: 12px; + right: max(12px, anchor(right)); + justify-self: end; +} + +@position-try --dsh-schedule-task-above-end { + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + bottom: max(12px, calc(anchor(top) + 4px)); + left: 12px; + right: max(12px, anchor(right)); + align-self: end; + justify-self: end; +} + +@position-try --dsh-schedule-task-start { + left: max(12px, anchor(left)); + right: 12px; + justify-self: start; +} + +@position-try --dsh-schedule-task-above-start { + top: max(12px, var(--dsh-frame-overlay-top, 12px)); + bottom: max(12px, calc(anchor(top) + 4px)); + left: max(12px, anchor(left)); + right: 12px; + align-self: end; + justify-self: start; +} + +@position-try --dsh-schedule-task-window { + position-area: none; + inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px; + margin: 0; + align-self: safe end; + justify-self: safe end; +} diff --git a/packages/client/ui-schedule/src/client/TaskMenu.tsx b/packages/client/ui-schedule/src/client/TaskMenu.tsx index 6805ea9852..cb2bd21021 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.tsx +++ b/packages/client/ui-schedule/src/client/TaskMenu.tsx @@ -6,12 +6,12 @@ * the Time zone row's search box, so the four call sites here keep that * capability locally instead of extending the shared card. */ -import { useCallback, useEffect, useRef } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import { useCallback, useEffect, useId, useRef } from 'react' +import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { - IconCheckOutlineRegular, isMobileDevice, useAnchoredPosition, useDismissOnOutsidePointer, + IconCheckOutlineRegular, isMobileDevice, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './TaskMenu.module.css' @@ -61,7 +61,7 @@ export interface TaskMenuProps { onClose: () => void /** List alignment against the anchor (default 'start'). */ align?: 'start' | 'end' - /** Render the list into document.body, fixed-positioned from the anchor rect. */ + /** Render the list into document.body, fixed-positioned by its CSS anchor. */ portal?: boolean /** Owner content pinned above the scrolling rows. */ header?: ReactNode @@ -71,15 +71,6 @@ export interface TaskMenuProps { listClassName?: string | undefined } -/** Unplaced portal list: hidden but laid out at a fixed origin so its offset size is real. */ -const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } - -/** Distance the list keeps from the anchor edge. */ -const ANCHOR_GAP = 4 - -/** Distance the list keeps from each viewport edge. */ -const VIEWPORT_MARGIN = 12 - /** * Whether an entry is a group hairline. * @param entry - one menu entry. @@ -112,16 +103,17 @@ function isLabel(entry: TaskMenuEntry): entry is TaskMenuLabel { * window blur that moved focus into a cross-origin iframe. * @param props.align - list alignment against the anchor (default 'start'). * @param props.portal - render the list into document.body, fixed-positioned - * from the anchor rect (follows scroll and resize while open). + * by its CSS anchor (follows scroll and resize while open). * @param props.header - owner content pinned above the scrolling rows; a control - * it marks `data-menu-field` takes the keyboard on desktop once the list is placed, - * which is the frame a portaled list becomes focusable in. Phones and tablets + * it marks `data-menu-field` takes the keyboard on desktop when opened. Phones and tablets * require explicit field focus. * @param props.className - extra class on the anchor wrapper span. * @param props.listClassName - extra class on the dropdown card itself. * @returns the anchor wrapper with the conditional list. */ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClose, align = 'start', portal = false, header, className, listClassName }: TaskMenuProps) { + const id = useId() + const anchorName = `--dsh-task-menu-${id.replaceAll(':', '')}` const rootRef = useRef(null) const listRef = useRef(null) /** Index the arrow walk last focused, the resume point when focus left the rows. */ @@ -135,9 +127,6 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo closeRef.current = onClose const dismiss = useCallback(() => { closeRef.current() }, []) - const position = useAnchoredPosition({ - open: open && portal, anchorRef: rootRef, panelRef: listRef, align, gap: ANCHOR_GAP, margin: VIEWPORT_MARGIN, - }) useDismissOnOutsidePointer(rootRef, open, dismiss, listRef) /* jscpd:ignore-start -- schedule-local copy of the shared ui-primitives Menu; the two owners change independently. */ @@ -165,12 +154,7 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo } /* jscpd:ignore-end */ - // A portaled list paints hidden until the placement effect measures it, and a - // hidden control refuses focus, so neither a header field's own autoFocus nor - // this effect can take the keyboard in the opening commit. Once the list is - // placed, a header control marked `data-menu-field` takes it. Focus is handed - // over once per open, so a reposition while the reader is on a row does not - // pull the keyboard back. + // Header fields take focus once per open; mobile fields require an explicit tap. useEffect(() => { if (!open) { handedOver.current = false @@ -180,14 +164,11 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo const list = listRef.current /* v8 ignore next -- the list is rendered in the same commit that sets `open`, before this effect runs. */ if (list === null) return - // A portaled list paints hidden until the position hook has measured it, - // and a hidden control refuses focus: the handover waits for that frame. - if (portal && position === null) return const field = list.querySelector('[data-menu-field]') if (field === null) return handedOver.current = true field.focus() - }, [open, portal, position]) + }, [open]) useEffect(() => { if (!open) { @@ -313,14 +294,11 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo ) } - // Portal lists render hidden until placed: the position hook measures this - // pre-render in the same commit, so the first painted frame is already at - // the final coordinates. const list = open && (
+ {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 623fc38a63..b6885853b4 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -268,11 +268,11 @@ describe('ScheduleCatalogAction visibility', () => { }) describe('ScheduleCatalogAction positioning', () => { - it('portals the catalog to the body and left-aligns it when space is available', () => { + it('portals the catalog to the body and binds it to the trigger CSS anchor', () => { const active = [entry('active', START + 60_000), entry('next', START + 120_000)] const view = render() const trigger = screen.getByRole('button', { name: '2 automation tasks' }) - vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + const measure = vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ x: 240, y: 20, left: 240, @@ -289,8 +289,11 @@ describe('ScheduleCatalogAction positioning', () => { const catalog = screen.getByRole('list', { name: en['list.aria'] }) expect(view.container.contains(catalog)).toBe(false) expect(catalog.parentElement).toBe(document.body) - expect(catalog.style.left).toBe('240px') - expect(catalog.style.top).toBe('53px') + expect(trigger.style.anchorName).not.toBe('') + expect(catalog.style.positionAnchor).toBe(trigger.style.anchorName) + expect(catalog.style.left).toBe('') + expect(catalog.style.top).toBe('') + expect(measure).not.toHaveBeenCalled() expect(catalog.style.visibility).toBe('') }) }) diff --git a/packages/client/ui-schedule/tests/task-menu.client.spec.tsx b/packages/client/ui-schedule/tests/task-menu.client.spec.tsx index 9da92427ba..a40ce443d6 100644 --- a/packages/client/ui-schedule/tests/task-menu.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-menu.client.spec.tsx @@ -50,6 +50,22 @@ function OpenMenu({ onSelect, portal = false }: { } describe('TaskMenu', () => { + it('assigns independent CSS anchors to portaled menus without measuring their bounds', () => { + const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + onTestFinished(() => { measure.mockRestore() }) + const view = render(<> + first trigger} items={ITEMS} onSelect={() => {}} onClose={() => {}} /> + second trigger} items={ITEMS} onSelect={() => {}} onClose={() => {}} /> + ) + const anchors = ['first trigger', 'second trigger'].map(label => screen.getByRole('button', { name: label }).parentElement!.style.anchorName) + expect(anchors.every(name => name !== '')).toBe(true) + expect(new Set(anchors).size).toBe(2) + const menus = screen.getAllByRole('menu') + expect(menus.map(menu => menu.style.positionAnchor)).toEqual(anchors) + expect(menus.every(menu => !view.container.contains(menu) && menu.style.left === '' && menu.style.top === '')).toBe(true) + expect(measure).not.toHaveBeenCalled() + }) + it('shows items only while open, and reports the clicked row id', () => { const onSelect = vi.fn() const { rerender } = render( @@ -201,8 +217,7 @@ describe('TaskMenu', () => { const alpha = screen.getByRole('menuitem', { name: 'Alpha' }) expect(document.activeElement).toBe(alpha) - // Placement runs again on scroll and resize, and on the panel's own size - // change; the handover is once per open, so a reader on a row keeps it. + // A viewport resize must not repeat the initial header focus handover. fireEvent(window, new Event('resize')) expect(document.activeElement).toBe(alpha) }) diff --git a/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx b/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx index 5c6b880e91..80c99af5fe 100644 --- a/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx +++ b/packages/client/ui-schedule/tests/timing-pickers.client.spec.tsx @@ -17,6 +17,7 @@ beforeEach(() => { // jsdom has no `scrollIntoView`, which the open clock panel calls. Element.prototype.scrollIntoView = vi.fn() anchor = document.createElement('button') + anchor.style.anchorName = '--test-timing-anchor' document.body.append(anchor) }) afterEach(() => { cleanup(); vi.useRealTimers(); anchor.remove() }) @@ -27,10 +28,25 @@ afterEach(() => { cleanup(); vi.useRealTimers(); anchor.remove() }) * @returns the props both pickers take, with spied pick and close callbacks. */ function panel(value: string) { - return { open: true, anchorRef: { current: anchor }, value, onPick: vi.fn(), onClose: vi.fn(), t } + return { open: true, anchorRef: { current: anchor }, anchorName: '--test-timing-anchor', value, onPick: vi.fn(), onClose: vi.fn(), t } } describe('ClockPicker', () => { + it('uses the row CSS anchor and dismisses only outside the trigger and panel', () => { + const props = panel('09:30:00') + render() + const clock = screen.getByRole('dialog', { name: en['timing.time'] }) + expect(clock.parentElement).toBe(document.body) + expect(clock.style.positionAnchor).toBe(anchor.style.anchorName) + expect(clock.style.left).toBe('') + expect(clock.style.top).toBe('') + fireEvent.pointerDown(anchor) + fireEvent.pointerDown(clock) + expect(props.onClose).not.toHaveBeenCalled() + fireEvent.pointerDown(document.body) + expect(props.onClose).toHaveBeenCalledOnce() + }) + it('starts a value without a clock at midnight', () => { const props = panel('') render() diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index f2f07812f8..679e70b4f6 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -27,8 +27,8 @@ en: 595499992ceea693 zh: 83c6147c46a80dca /deepseek-ai-dsh-client-ui-sidebar-documentpreview/office-preview: - en: 62799a1372b966db - zh: e690f9cc467006e6 + en: 9178629b27e9dc87 + zh: 1692e7f9d73a0653 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/navigation: en: 33215200b3e4a8ed zh: 499e4b834f2db95a diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 98b41281c3..dc023a7116 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -109,7 +109,7 @@ Opening an Office file requests conversion on demand. Each read checks renderer Background requests leave the final pending-request and reader slots available for foreground work; setting either limit to one rejects background reads. Renderer replacement restarts generation discovery and authorization once. Another replacement during that retry reports the localized busy message. -A yellow rounded-triangle warning appears before Reload in the document toolbar when the current Office preview has missing fonts. Hover or keyboard focus shows the missing-font count; clicking opens an anchored list that follows browser zoom, Visual Viewport panning, and resizing. Focus enters the list after its first position resolves. Escape, Close, or clicking outside closes the list without hiding the warning or moving the document. Reloading closes the old details; previews with no missing fonts show no warning. Viewed and dismissed states are not retained. +A yellow rounded-triangle warning appears before Reload in the document toolbar when the current Office preview has missing fonts. Hover or keyboard focus shows the missing-font count; clicking opens a CSS-anchored list and moves focus into it. The list follows its trigger within layout-viewport size limits; browser zoom and panning move it with the page, so it may extend beyond the visual viewport. Escape, Close, or clicking outside closes the list without hiding the warning or moving the document. Reloading closes the old details; previews with no missing fonts show no warning. Viewed and dismissed states are not retained.
Office implementation — click to expand diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 87d2f66355..a274c720da 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -109,7 +109,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本;CSV 还提供 后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。 -当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开锚定字体列表,列表跟随浏览器缩放、Visual Viewport 平移及尺寸变化,并在首次定位完成后接收焦点。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。 +当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开 CSS 锚定的字体列表,并将焦点移入其中。列表跟随触发器,并按布局视口限制尺寸;浏览器缩放和平移时列表随页面移动,因此可以超出视觉视口。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。
Office 实现——点击展开 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css index 419f96773c..2ef0ab0b67 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -30,7 +30,7 @@ z-index: 100; box-sizing: border-box; width: min(340px, calc(100vw - 24px)); - max-height: min(calc(100dvh - 24px), 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; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx index 061d639777..84a5e36d57 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx @@ -3,7 +3,7 @@ import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import { Button, IconCloseOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconCloseOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' import common from '../TextPreview.module.css' import css from './FontNotice.module.css' @@ -23,22 +23,21 @@ export function FontNotice({ fonts, t }: FontNoticeProps): ReactNode { const anchor = useRef(null) const panel = useRef(null) const id = useId() - const position = useAnchoredPosition({ open, anchorRef: anchor, panelRef: panel, gap: 8, margin: 12 }) + const anchorName = `--dsh-font-notice-${id.replaceAll(':', '')}` useDismissOnOutsidePointer(anchor, open, () => { setExpanded(false) }, panel) const closeDetails = (): void => { setExpanded(false) anchor.current?.querySelector('button')?.focus() } - const positioned = position !== null useLayoutEffect(() => { - if (!open || !positioned) return + if (!open) return panel.current?.focus() - }, [open, positioned]) + }, [open]) if (fonts.length === 0) return null const label = t('viewMissingFonts', { count: fonts.length }) return <> - + {open && createPortal( { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true) } }} diff --git a/packages/experimental/session-inspector/src/client/views/inspector.module.css b/packages/experimental/session-inspector/src/client/views/inspector.module.css index 4b333fd24d..2cce7560f9 100644 --- a/packages/experimental/session-inspector/src/client/views/inspector.module.css +++ b/packages/experimental/session-inspector/src/client/views/inspector.module.css @@ -43,7 +43,25 @@ @media (hover: hover) { .typeFilterButton:hover { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); } } -.typeFilterPanel { position: fixed; z-index: 1250; width: 290px; max-width: min(calc(100vw - 24px), 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; +}