From 3ec8cd925d59b9fc75f69faa7777fb030bf61198 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 28 Sep 2026 18:12:48 +0800 Subject: [PATCH] perf(web): observe model menu stickiness without layout reads --- apps/web/tests/model-menu-observer.e2e.ts | 289 +++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 +- packages/client/ui-commands/README.zh.md | 2 +- .../src/client/PopupSelectView.tsx | 4 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 2 +- .../tests/model-select.client.spec.tsx | 40 -- .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/MenuGroup.module.css | 14 + .../client/ui-primitives/src/MenuGroup.tsx | 105 ++-- .../tests/menu-group.client.spec.tsx | 481 ++++++++++++------ tsconfig.host.json | 1 + 18 files changed, 729 insertions(+), 244 deletions(-) create mode 100644 apps/web/tests/model-menu-observer.e2e.ts diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts new file mode 100644 index 0000000000..5da1f9e2f2 --- /dev/null +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -0,0 +1,289 @@ +/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */ +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium, webkit } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +declare global { + interface Window { + /** Test-private count for MenuGroup sections and their direct scroll viewport. */ + __modelMenuRectReads: number + } +} + +const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url)) +const PROVIDERS = [ + { id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' }, + { id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' }, + { id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' }, + { id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' }, +].map((provider, providerIndex) => ({ + ...provider, + models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0 + ? { id: 'origin-large', name: 'Origin Large' } + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), +})) +const EXPECTED_GROUPS = [ + { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, + ...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })), +] + +// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps. +async function frames(page: Page): Promise { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) +} + +function readGroups(viewport: Locator) { + return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent), + }))) +} + +function readPinned(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + headings.map(heading => heading.hasAttribute('data-stuck'))) +} + +async function assertNoRectReads(page: Page): Promise { + await frames(page) + expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0) +} + +async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise { + const expected = Array.from({ length: count }, (_, i) => i === index) + await expect.poll(() => readPinned(viewport)).toEqual(expected) + await assertNoRectReads(page) + expect(await readPinned(viewport)).toEqual(expected) +} + +async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise { + // Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation. + await viewport.evaluate((node, target) => new Promise((resolve) => { + requestAnimationFrame(() => { + const groups = node.querySelectorAll(':scope > [data-menu-group]') + node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside + resolve() + }) + }), { index, inside }) +} + +async function headingMaterial(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => { + const body = heading.ownerDocument.body + const theme = body.getAttribute('data-ds-dark-theme') + try { + return [false, true].map((dark) => { + body.toggleAttribute('data-ds-dark-theme', dark) + const style = getComputedStyle(heading) + return { + fill: style.backgroundColor, radius: style.borderRadius, position: style.position, + font: style.fontSize, weight: style.fontWeight, padding: style.padding, + } + }) + } finally { + if (theme === null) body.removeAttribute('data-ds-dark-theme') + else body.setAttribute('data-ds-dark-theme', theme) + } + }) +} + +describe.skipIf(webSnapshotMode() === 'record').each([ + { name: 'Chromium', engine: chromium }, + { name: 'WebKit', engine: webkit }, +])('web e2e: model menu observers ($name)', ({ engine }) => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let crashed = false + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // Catalog-only settings traffic: no prompt, selection, replay fixture, or model request. + await scaffold.ctx.settings.update('llm-pi-ai', { + providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, { + displayName: provider.name, + api: 'openai-completions', + baseURL: `https://${provider.id}.example/v1`, + models: provider.models, + }])), + }) + browser = await engine.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.addInitScript(() => { + window.__modelMenuRectReads = 0 + // oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below. + const original = Element.prototype.getBoundingClientRect + Element.prototype.getBoundingClientRect = function (): DOMRect { + if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) { + window.__modelMenuRectReads++ + } + return original.call(this) + } + }) + page.on('crash', () => { crashed = true; console.error('observer test page crashed') }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + it('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { + onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`)) + let buttonMaterial: Awaited> | undefined + for (const entry of ['button', 'command'] as const) { + const composer = page.locator('[data-composer-input]').first() + const surface = entry === 'button' + ? page.getByRole('group', { name: '模型与推理等级', exact: true }) + : page.locator('[aria-label="/model 选项"]') + const viewport = entry === 'button' + ? surface.getByRole('menu', { name: '模型', exact: true }) + : surface.getByRole('listbox') + const search = entry === 'button' + ? page.getByRole('searchbox', { name: '搜索模型…', exact: true }) + : page.getByRole('textbox', { name: '筛选选项', exact: true }) + const open = async (): Promise => { + if (entry === 'button') { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + } else { + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + } + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + await page.mouse.move(0, 0) + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + await assertPinned(page, viewport, -1) + } + const close = async (): Promise => { + if (crashed || page.isClosed() || await surface.count() === 0) return + await search.press('Escape') + if (entry === 'button') await page.keyboard.press('Escape') + await surface.waitFor({ state: 'detached' }) + await composer.fill('') + } + + await open() + try { + expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))])) + .toEqual(['rgba(0, 0, 0, 0)']) + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + const material = await headingMaterial(viewport) + expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)']) + for (const style of material) { + expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' }) + } + if (entry === 'button') buttonMaterial = material + else expect(material).toEqual(buttonMaterial) + + for (const index of [2, 4, 1]) { + await scrollToGroup(viewport, index) + await assertPinned(page, viewport, index) + const height = await viewport.evaluate(node => node.clientHeight) + expect(height).toBeGreaterThan(0) + expect(height).toBeLessThanOrEqual(80) + expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight)) + .toBeGreaterThan(height * 2) + await scrollToGroup(viewport, index, height + 1) + await assertPinned(page, viewport, index) + expect(await viewport.evaluate((node, groupIndex) => { + const headings = node.querySelectorAll('[data-menu-group-heading]') + const firstStart = node.querySelector('[data-menu-group-start]')! + const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop + return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop) + }, index)).toBeLessThan(1) + } + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + + for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) { + const visited = new Set() + let observedPinnedHighlight = false + for (let step = 0; step < steps; step++) { + const rows = viewport.locator('[role="option"], [role="menuitemradio"]') + const before = await rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true')) + await search.press(key) + const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30 + await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected) + await frames(page) + await expect.poll(() => viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const base = groups[0]!.offsetTop + return groups.every((group) => { + const top = group.offsetTop - base + const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop + return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick + }) + })).toBe(true) + const highlight = await viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null) + const group = groups[index]! + return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop, + stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') } + }) + visited.add(highlight.index) + if (highlight.above) { + expect(highlight.stuck).toBe(true) + observedPinnedHighlight = true + } + expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + } + expect(visited.size).toBeGreaterThan(1) + expect(observedPinnedHighlight).toBe(true) + } + + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + await search.fill('Quartz Model 1') + await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }]) + await assertPinned(page, viewport, -1, 1) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + await search.fill('') + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await assertPinned(page, viewport, -1) + await close() + await open() + expect(await search.inputValue()).toBe('') + + await scrollToGroup(viewport, 3, 90) + await assertPinned(page, viewport, 3) + await viewport.evaluate((node) => { node.style.maxHeight = '64px' }) + await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64) + await assertPinned(page, viewport, 3) + await scrollToGroup(viewport, 4, 100) + await assertPinned(page, viewport, 4) + await scrollToGroup(viewport, 1, 100) + await assertPinned(page, viewport, 1) + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + } finally { + await close() + } + } + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f5f0300f04..e213a2ab14 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -87,6 +87,7 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/model-menu-observer.e2e.ts", "tests/default-workspace.e2e.ts", "tests/new-session-refused.e2e.ts", "tests/github-ready-review.e2e.ts", diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 0b344dbeac..ba51e078fb 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: 9fffb036b0346871 - zh: 1e0364e9b4f5a24b + en: 3aeeba6c91c3ef19 + zh: d3afb3df6d15e17b /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 0cba9477f4..eb354816d2 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -50,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl ## Understand the implementation -Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; the view cleans up and rebuilds the observer when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). +Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; an ordinary effect owns the asynchronous observation and rebuilds it when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
Implementation internals — click to expand diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index bfa03d9093..de78e36758 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -50,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen ## 理解实现 -弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;渲染分组变化时,视图清理并重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。 +弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;普通 effect 负责异步观察,并在渲染分组变化时清理和重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
实现细节——点击展开 diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 355b913502..4b0b4bf418 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -11,7 +11,7 @@ * above the composer. */ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' -import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { Fragment, useEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' @@ -50,7 +50,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { const rows = useMemo(() => filterOptions(state.options, state.search, state.searchMode), [state.options, state.search, state.searchMode]) const groups = useMemo(() => groupOptions(rows), [rows]) - useLayoutEffect(() => { + useEffect(() => { const viewport = viewportRef.current if (viewport === null) return return observeStickyMenuGroups(viewport) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 804d66694f..9c22fdfbdf 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 62235f8c8c9e1e7e zh: c18dc9c38ef9d4f9 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 5e2ec95807548cb4 - zh: b47754c9cdf2d068 + en: 48f8157ffdfe6e69 + zh: 64ec9a533e2aa8f4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 77080c3a4d..09b016ef3a 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -41,7 +41,7 @@ The button's model menu shows search only when its full catalog contains more th Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field. -Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared sticky-heading presentation from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared, asynchronously observed sticky headings from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 9894348717..12df7a98ed 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -41,7 +41,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 -两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 异步观察的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index f3524df753..fbeda5d31e 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -191,7 +191,7 @@ export function ModelSelect( ;(cell !== null && cell !== undefined && !cell.disabled ? cell : triggerRef.current)?.focus() }, [open, pane, showSearch]) - useLayoutEffect(() => { + useEffect(() => { const viewport = groupsRef.current if (viewport === null) return return observeStickyMenuGroups(viewport) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 0341daa436..470c852ec8 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -852,46 +852,6 @@ describe('ModelSelect search', () => { }) }) -it('paints only a pinned provider heading and clears its state when the pane closes', () => { - const directory = createSnapshotStore(state({ groups: [ - ...state().groups, - { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, - ] })) - render() - const trigger = screen.getByRole('button', { name: /选择模型/ }) - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - const viewport = screen.getByRole('menu', { name: '模型' }) - const sections = [...viewport.querySelectorAll('section')] - const headings = sections.map(section => section.querySelector(':scope > div')!) - let firstHeight = 90 - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 240, 80)) - vi.spyOn(sections[0]!, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 100 - viewport.scrollTop, 240, firstHeight)) - vi.spyOn(sections[1]!, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 103 + firstHeight - viewport.scrollTop, 240, 56)) - const pinned = (): boolean[] => headings.map(heading => heading.hasAttribute('data-stuck')) - fireEvent.scroll(viewport, { target: { scrollTop: 0 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 24 } }) - expect(pinned()).toEqual([true, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 93 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 94 } }) - expect(pinned()).toEqual([false, true]) - firstHeight = 130 - fireEvent.resize(window) - expect(pinned()).toEqual([true, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 0 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 24 } }) - const removeScroll = vi.spyOn(viewport, 'removeEventListener') - fireEvent.click(trigger) - expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) - expect(pinned()).toEqual([false, false]) - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - expect(screen.getByRole('menu', { name: '模型' }).querySelector('[data-stuck]')).toBeNull() -}) - it('shows the unselected model control with the inherited effort', async () => { const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 517377a20c..cff51aed21 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 91ab0b7b84d060be - zh: 6d6e2882f58e71f6 + en: 86ebc31d3af22478 + zh: 6f9f75d43e67049e /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d @@ -30,8 +30,8 @@ en: 4aef65dea11de083 zh: abc22cd7c65cdf38 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 6d228f9141c804aa - zh: 04bdfc8f3673fcd0 + en: 0b4082dbb778476f + zh: b6daaaf9de675b57 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: 44eb32370cbeafe8 zh: 728009c66375c6bb diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1e3116dc13..2ccc2b61e8 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | -| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and scroll/resize observation; the caller owns the observer's lifetime. | +| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | @@ -121,7 +121,9 @@ The atoms cannot read the application locale, so every piece of user-facing copy `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. -Call `observeStickyMenuGroups(viewport)` after rendering `MenuGroup` sections as direct children of the scroll container. It updates synchronously, then on scrolling, window resize, and viewport or section resizing when `ResizeObserver` is available. A heading is stuck only while its section straddles the viewport top and `scrollTop` is positive. Group membership is captured at setup: call the returned cleanup and observe again whenever rendered groups change, including filtering, and clean up on unmount. Cleanup removes listeners, disconnects size observation, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no listeners or observer. +Call `observeStickyMenuGroups(viewport)` from an ordinary effect after rendering `MenuGroup` sections as direct children of an unpadded, borderless scroll container. Native intersection and viewport-size observations update the background asynchronously, without synchronous layout reads or scroll listeners. Headings remain transparent until observations identify a group crossing the viewport top; CSS owns their sticky positioning. + +Group membership is captured at setup. Dispose before observing changed groups, including after filtering, and clean up on unmount. Cleanup disconnects the observers, ignores queued callbacks, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no observers. Without `IntersectionObserver` or `ResizeObserver`, headings keep CSS sticky positioning but remain transparent.
Implementation internals — click to expand diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9dc080c933..588fb26041 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | -| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与滚动/尺寸观察;调用方负责观察器的生命周期。 | +| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | @@ -121,7 +121,9 @@ kind: "package-library" `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 -将 `MenuGroup` 区段渲染为滚动容器的直接子节点后,调用 `observeStickyMenuGroups(viewport)`。它同步更新状态,随后响应滚动、窗口尺寸变化,以及可用 `ResizeObserver` 观察到的滚动区或区段尺寸变化。只有区段跨过滚动区顶部且 `scrollTop` 为正时,标题才处于吸顶状态。分组成员在初始化时确定:渲染分组发生变化时(包括筛选),必须调用返回的清理函数并重新观察,卸载时也必须清理。清理会移除监听器、断开尺寸观察并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建监听器或观察器。 +将 `MenuGroup` 区段渲染为无内边距、无边框的滚动容器的直接子节点后,在普通 effect 中调用 `observeStickyMenuGroups(viewport)`。原生交叉观察与滚动区尺寸观察异步更新背景,不同步读取布局,也不注册滚动监听器。标题保持透明,直到观察结果确认分组跨过滚动区顶部;吸顶定位由 CSS 负责。 + +分组成员在初始化时确定。分组变化后(包括筛选)重新观察前必须清理,卸载时也必须清理。清理会断开观察器、忽略排队回调并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建观察器。缺少 `IntersectionObserver` 或 `ResizeObserver` 时,标题仍通过 CSS 吸顶,但保持透明。
实现细节——点击展开 diff --git a/packages/client/ui-primitives/src/MenuGroup.module.css b/packages/client/ui-primitives/src/MenuGroup.module.css index 3065fdc27c..f4b054e9bd 100644 --- a/packages/client/ui-primitives/src/MenuGroup.module.css +++ b/packages/client/ui-primitives/src/MenuGroup.module.css @@ -1,3 +1,17 @@ +.group { + position: relative; +} + +.start { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + .group + .group { margin-top: 3px; } diff --git a/packages/client/ui-primitives/src/MenuGroup.tsx b/packages/client/ui-primitives/src/MenuGroup.tsx index ce77a4eab6..bd26bc0249 100644 --- a/packages/client/ui-primitives/src/MenuGroup.tsx +++ b/packages/client/ui-primitives/src/MenuGroup.tsx @@ -3,7 +3,7 @@ import { useId, type ReactNode } from 'react' import css from './MenuGroup.module.css' /** - * Render a named group with an instance-owned heading id. + * Render a named group with an instance-owned heading id and an inaccessible position sentinel. * @param props - Caller-localized label and optional menu rows. * @returns A section named by its direct heading, followed by the supplied children. */ @@ -11,6 +11,7 @@ export function MenuGroup({ label, children }: { label: string; children?: React const headingId = useId() return (
+
@@ -18,39 +19,85 @@ export function MenuGroup({ label, children }: { label: string; children?: React } /** - * Update direct menu-group headings synchronously, then on scroll and resize. - * A heading is stuck only while its section straddles the viewport top and scrollTop is positive. - * Group membership is captured at setup; dispose and observe again when the rendered groups change. - * @param viewport - Scroll container whose direct groups contain direct data-menu-group-heading children. - * @returns Cleanup that removes listeners, disconnects size observation, and clears every managed - * heading's data-stuck attribute. A viewport without groups acquires no listeners or observer. + * Update heading backgrounds asynchronously from native intersection and viewport-size observations. + * Headings remain transparent until observations identify a section crossing the viewport top. + * Without IntersectionObserver or ResizeObserver, CSS sticky headings remain transparent. + * Group membership is captured at setup; dispose before observing changed groups or the same viewport again. + * @param viewport - Unpadded, borderless scroll container with direct MenuGroup children. + * @returns Cleanup owning both intersection observers and the viewport resize observer; disconnects + * them, ignores queued callbacks, and clears managed data-stuck attributes. No valid groups acquires nothing. */ export function observeStickyMenuGroups(viewport: HTMLElement): () => void { - const entries = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { + const groups = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { const heading = section.querySelector(':scope > [data-menu-group-heading]') - return heading === null ? [] : [{ section, heading }] + const start = section.querySelector(':scope > [data-menu-group-start]') + return heading === null || start === null ? [] : [{ + section, heading, start, atTop: false, above: false, topTime: -Infinity, aboveTime: -Infinity, + }] }) - if (entries.length === 0) return () => {} - - const update = (): void => { - const top = viewport.getBoundingClientRect().top - // Section boxes retain their normal positions while their headings stick. - const stuck = entries.map(({ section }) => { - const bounds = section.getBoundingClientRect() - return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top - }) - entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) + if (groups.length === 0 || typeof IntersectionObserver === 'undefined' || typeof ResizeObserver === 'undefined') { + return () => {} } - update() - viewport.addEventListener('scroll', update, { passive: true }) - window.addEventListener('resize', update) - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) - observer?.observe(viewport) - for (const { section } of entries) observer?.observe(section) + + const sections = new Map(groups.map(group => [group.section, group])) + const starts = new Map(groups.map(group => [group.start, group])) + let disposed = false + let stripObserver: IntersectionObserver | undefined + let viewportHeight: number | undefined + const render = (group: typeof groups[number]): void => { + const stuck = group.atTop && group.above + if (group.heading.hasAttribute('data-stuck') !== stuck) group.heading.toggleAttribute('data-stuck', stuck) + } + const startObserver = new IntersectionObserver((entries) => { + if (disposed) return + for (const entry of entries) { + const group = starts.get(entry.target) + if (!group || entry.rootBounds === null || entry.time < group.aboveTime) continue + group.above = entry.boundingClientRect.top < entry.rootBounds.top + group.aboveTime = entry.time + render(group) + } + }, { root: viewport, threshold: [0, 1] }) + for (const { start } of groups) startObserver.observe(start) + + const sizeObserver = new ResizeObserver((entries) => { + if (disposed) return + for (const entry of entries) { + if (entry.target !== viewport || entry.contentRect.height === viewportHeight) continue + viewportHeight = entry.contentRect.height + stripObserver?.disconnect() + // A full section crossing this strip is observable even when scrolling skips its start sentinel. + const observer = new IntersectionObserver((intersections) => { + if (disposed || stripObserver !== observer) return + for (const intersection of intersections) { + const group = sections.get(intersection.target) + if (!group || intersection.rootBounds === null) continue + const top = intersection.rootBounds.top + if (intersection.time >= group.topTime) { + group.atTop = intersection.isIntersecting && intersection.boundingClientRect.bottom > top + group.topTime = intersection.time + } + // Both observers describe the section's normal-flow start; the newest sample wins. + if (intersection.time >= group.aboveTime) { + group.above = intersection.boundingClientRect.top < top + group.aboveTime = intersection.time + } + render(group) + } + }, { root: viewport, rootMargin: `0px 0px ${Math.min(1, viewportHeight) - viewportHeight}px 0px`, threshold: 0 }) + stripObserver = observer + for (const { section } of groups) observer.observe(section) + } + }) + sizeObserver.observe(viewport) return () => { - viewport.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - observer?.disconnect() - for (const { heading } of entries) heading.removeAttribute('data-stuck') + if (disposed) return + disposed = true + startObserver.disconnect() + stripObserver?.disconnect() + sizeObserver.disconnect() + for (const { heading } of groups) { + if (heading.hasAttribute('data-stuck')) heading.removeAttribute('data-stuck') + } } } diff --git a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx index c444fb01ac..52ff44099c 100644 --- a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx @@ -1,13 +1,50 @@ // @vitest-environment jsdom /** Menu-group naming, sticky transitions, and viewport-observer ownership. */ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, isInaccessible, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MenuGroup, observeStickyMenuGroups } from '../src/MenuGroup.tsx' const disposers: (() => void)[] = [] +const intersections: StubIntersectionObserver[] = [] +const sizes: StubResizeObserver[] = [] + +class StubIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '0px' + readonly thresholds: readonly number[] + observe = vi.fn<(target: Element) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => []) + constructor(private readonly callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) { + this.root = options.root ?? null + this.rootMargin = options.rootMargin ?? '0px' + this.thresholds = typeof options.threshold === 'number' ? [options.threshold] : options.threshold ?? [0] + intersections.push(this) + } + notify(...entries: IntersectionObserverEntry[]): void { this.callback(entries, this) } +} + +class StubResizeObserver implements ResizeObserver { + observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + constructor(private readonly callback: ResizeObserverCallback) { sizes.push(this) } + notify(target: Element, height = 200): void { + const size: ResizeObserverSize = { blockSize: height, inlineSize: 200 } + this.callback([{ + target, contentRect: new DOMRect(0, 0, 200, height), + borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], + }], this) + } +} beforeEach(() => { - vi.stubGlobal('ResizeObserver', undefined) + intersections.length = 0 + sizes.length = 0 + vi.stubGlobal('IntersectionObserver', StubIntersectionObserver) + vi.stubGlobal('ResizeObserver', StubResizeObserver) }) afterEach(() => { @@ -23,197 +60,329 @@ function observe(viewport: HTMLElement): () => void { return dispose } +function directChild(section: HTMLElement, attribute: string): HTMLElement { + const child = section.querySelector(`:scope > [${attribute}]`) + if (!child) throw new Error(`Missing ${attribute}`) + return child +} + function fixture() { - render( + const view = render(
+
, ) const viewport = screen.getByTestId('viewport') - const first = screen.getByRole('group', { name: 'Provider A' }) - const second = screen.getByRole('group', { name: 'Provider B' }) - const firstHeading = within(first).getByText('Provider A') - const secondHeading = within(second).getByText('Provider B') - const viewportRect = vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) - const firstRect = vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 100)) - const secondRect = vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 203, 200, 100)) - return { viewport, first, second, firstHeading, secondHeading, viewportRect, firstRect, secondRect } + const groups = screen.getAllByRole('group').map(section => ({ + section, heading: directChild(section, 'data-menu-group-heading'), start: directChild(section, 'data-menu-group-start'), + })) + return { ...view, viewport, groups } +} + +function entry( + target: Element, top: number, height: number, time: number, rootHeight = 1, + rootBounds: DOMRectReadOnly | null = new DOMRect(0, 100, 200, rootHeight), +): IntersectionObserverEntry { + const bottom = top + height + const intersectionTop = Math.max(top, 100) + const intersectionBottom = Math.min(bottom, 100 + rootHeight) + const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop) + return { + target, time, boundingClientRect: new DOMRect(0, top, 200, height), + rootBounds, + intersectionRect: new DOMRect(0, intersectionTop, 200, intersectionHeight), + intersectionRatio: intersectionHeight / height, isIntersecting: bottom >= 100 && top <= 100 + rootHeight, + } +} + +function startEntry(target: Element, top: number, time: number): IntersectionObserverEntry { + return entry(target, top, 1, time, 200) +} + +function startObserving(viewport: HTMLElement) { + const dispose = observe(viewport) + const start = intersections[0]! + const size = sizes[0]! + size.notify(viewport) + const strip = intersections[1]! + return { dispose, start, size, strip } } describe('MenuGroup', () => { - it('names each section with a unique direct heading and preserves child order', () => { - const { first, second, firstHeading, secondHeading } = fixture() - expect(first.tagName).toBe('SECTION') - expect(first.hasAttribute('data-menu-group')).toBe(true) - expect(firstHeading.hasAttribute('data-menu-group-heading')).toBe(true) - expect(first.getAttribute('aria-labelledby')).toBe(firstHeading.id) - expect(second.getAttribute('aria-labelledby')).toBe(secondHeading.id) - expect(firstHeading.id).not.toBe('') - expect(firstHeading.id).not.toBe(secondHeading.id) - expect(first.firstElementChild).toBe(firstHeading) - expect(firstHeading.nextElementSibling).toBe(within(first).getByRole('button', { name: 'Model A' })) + it('names each section with a unique direct heading and keeps the sentinel out of the accessible tree', () => { + const { groups: [first, second] } = fixture() + expect(first!.section.tagName).toBe('SECTION') + expect(first!.section.hasAttribute('data-menu-group')).toBe(true) + expect(first!.section.getAttribute('aria-labelledby')).toBe(first!.heading.id) + expect(second!.section.getAttribute('aria-labelledby')).toBe(second!.heading.id) + expect(first!.heading.id).not.toBe('') + expect(first!.heading.id).not.toBe(second!.heading.id) + expect(first!.start.getAttribute('aria-hidden')).toBe('true') + expect(isInaccessible(first!.start)).toBe(true) + expect(first!.start.textContent).toBe('') + expect(first!.start.hasAttribute('tabindex')).toBe(false) + expect(first!.heading.nextElementSibling).toBe(within(first!.section).getByRole('button', { name: 'Model A' })) }) it('renders a named group without rows', () => { render() const group = screen.getByRole('group', { name: 'Empty provider' }) - expect(group.children).toHaveLength(1) + expect(group.children).toHaveLength(2) expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id) }) }) describe('observeStickyMenuGroups', () => { - it('handles normal headings, sticking, handoff, and scrolling back without ResizeObserver', () => { - const { viewport, firstHeading, secondHeading, firstRect, secondRect } = fixture() - observe(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 183, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 100 - firstRect.mockReturnValue(new DOMRect(0, 0, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 103, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 103 - secondRect.mockReturnValue(new DOMRect(0, 100, 200, 100)) - fireEvent.scroll(viewport) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 104 - firstRect.mockReturnValue(new DOMRect(0, -4, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 99, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(true) - - viewport.scrollTop = 0 - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - }) - - it('synchronously updates an already-scrolled viewport and reads all boxes before writing attributes', () => { - const { viewport, firstHeading, secondHeading, viewportRect, firstRect, secondRect } = fixture() - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - const firstWrite = vi.spyOn(firstHeading, 'toggleAttribute') - const secondWrite = vi.spyOn(secondHeading, 'toggleAttribute') - observe(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - const reads = [...viewportRect.mock.invocationCallOrder, ...firstRect.mock.invocationCallOrder, ...secondRect.mock.invocationCallOrder] - const writes = [...firstWrite.mock.invocationCallOrder, ...secondWrite.mock.invocationCallOrder] - expect(Math.max(...reads)).toBeLessThan(Math.min(...writes)) - }) - - it('updates on window resize and removes scroll and resize listeners on cleanup', () => { - const { viewport, firstHeading, firstRect, viewportRect } = fixture() + it('does not synchronously read layout when observing a 30-model menu', () => { + render(
{Array.from({ length: 6 }, (_, index) => + + {Array.from({ length: 5 }, (_, row) => )} + )}
) + const viewport = screen.getByTestId('large-viewport') + const groups = screen.getAllByRole('group') + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const clientRects = vi.spyOn(Element.prototype, 'getClientRects') + const computedStyle = vi.spyOn(window, 'getComputedStyle') + const offsetTop = vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get') + const offsetHeight = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get') const addScroll = vi.spyOn(viewport, 'addEventListener') - const removeScroll = vi.spyOn(viewport, 'removeEventListener') - const removeResize = vi.spyOn(window, 'removeEventListener') + const addResize = vi.spyOn(window, 'addEventListener') const dispose = observe(viewport) - expect(addScroll).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - viewportRect.mockReturnValue(new DOMRect(0, 70, 200, 200)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - viewportRect.mockReturnValue(new DOMRect(0, 100, 200, 200)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - - dispose() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) - expect(removeResize).toHaveBeenCalledWith('resize', expect.any(Function)) - firstRect.mockClear() + expect(read).not.toHaveBeenCalled() + expect(intersections).toHaveLength(1) + expect(sizes).toHaveLength(1) + const start = intersections[0]! + const size = sizes[0]! + expect(start.observe).toHaveBeenCalledTimes(6) + expect(size.observe.mock.calls).toEqual([[viewport]]) + size.notify(viewport) + const strip = intersections[1]! + expect(strip.observe.mock.calls).toEqual(groups.map(group => [group])) + strip.notify(...groups.map((group, index) => entry(group, 100 + index * 203, 200, 1))) + start.notify(...groups.map((group, index) => startEntry(directChild(group, 'data-menu-group-start'), 100 + index * 203, 1))) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(false) + viewport.scrollTop = 0.25 fireEvent.scroll(viewport) + start.notify(startEntry(directChild(groups[0]!, 'data-menu-group-start'), 99.75, 2)) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(true) fireEvent.resize(window) - expect(firstRect).not.toHaveBeenCalled() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - }) - - it('observes the viewport and every section when ResizeObserver is available, then disconnects', () => { - const observeSize = vi.fn() - const disconnect = vi.fn() - let notify: () => void = () => { throw new Error('ResizeObserver has not been constructed') } - class StubResizeObserver implements ResizeObserver { - observe = observeSize - unobserve = vi.fn() - disconnect = disconnect - constructor(callback: ResizeObserverCallback) { - notify = () => { callback([], this) } - } - } - vi.stubGlobal('ResizeObserver', StubResizeObserver) - const { viewport, first, second, firstHeading, secondHeading, firstRect, secondRect } = fixture() - const dispose = observe(viewport) - expect(observeSize.mock.calls).toEqual([[viewport], [first], [second]]) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - notify() - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - firstRect.mockReturnValue(new DOMRect(0, -20, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 83, 200, 100)) - notify() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(true) - + size.notify(viewport, 180) + intersections[2]!.notify(entry(groups[0]!, 99.75, 200, 3)) dispose() - expect(disconnect).toHaveBeenCalledOnce() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + for (const spy of [read, clientRects, computedStyle, offsetTop, offsetHeight, clientHeight]) { + expect(spy).not.toHaveBeenCalled() + } + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() }) - it('ignores nested groups, unmarked sections, and headings that are not direct children', () => { - render( -
- - - -
Indirect
-
Unmarked
-
, - ) - const viewport = screen.getByTestId('viewport') - const direct = screen.getByRole('group', { name: 'Direct' }) - const nested = screen.getByRole('group', { name: 'Nested' }) - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) - vi.spyOn(direct, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 80, 200, 100)) - const nestedRect = vi.spyOn(nested, 'getBoundingClientRect') + it('waits for asynchronous size and intersection delivery, including an already-scrolled viewport', () => { + const { viewport, groups: [first] } = fixture() viewport.scrollTop = 20 observe(viewport) - expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) - expect(screen.getByText('Nested').hasAttribute('data-stuck')).toBe(false) - expect(screen.getByText('Indirect').hasAttribute('data-stuck')).toBe(false) - expect(screen.getByText('Unmarked').hasAttribute('data-stuck')).toBe(false) - expect(nestedRect).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(1) + intersections[0]!.notify(startEntry(first!.start, 80, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + sizes[0]!.notify(viewport) + intersections[1]!.notify(entry(first!.section, 80, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) }) - it('acquires no listeners or ResizeObserver for a viewport without direct groups', () => { - render(
) + it('keeps the initial top transparent, handles fractional crossing, and clears on upward return to zero', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + expect(start.root).toBe(viewport) + expect(start.thresholds).toEqual([0, 1]) + expect(strip.root).toBe(viewport) + expect(strip.rootMargin).toBe('0px 0px -199px 0px') + expect(strip.thresholds).toEqual([0]) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 100, 100, 1)) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).not.toHaveBeenCalled() + // The section still intersects the strip, so only the sentinel crosses a threshold. + start.notify(startEntry(first!.start, 99.75, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 99, 3)) + expect(write).toHaveBeenCalledTimes(1) + start.notify(startEntry(first!.start, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).toHaveBeenCalledTimes(2) + }) + + it('clears at the section tail and hands off only after the following group crosses the top', () => { + const { viewport, groups: [first, second] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1), entry(second!.section, 183, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 0, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, -3, 100, 3), entry(second!.section, 100, 100, 3)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + start.notify(startEntry(second!.start, 99.75, 4)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('finds the middle group after a fast jump even when its sentinel reports no new intersection', () => { + const { viewport, groups: [first, second, third] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 100, 1), startEntry(second!.start, 503, 1), startEntry(third!.start, 906, 1)) + strip.notify(entry(first!.section, 100, 400, 1), entry(second!.section, 503, 400, 1), entry(third!.section, 906, 400, 1)) + strip.notify(entry(first!.section, -450, 400, 2), entry(second!.section, -47, 400, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(third!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('orders samples from both observers by entry time rather than callback delivery', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 99.75, 3)) + strip.notify(entry(first!.section, 100, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + strip.notify(entry(first!.section, -1, 100, 5)) + strip.notify(entry(first!.section, 80, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 80, 100, 6)) + start.notify(startEntry(first!.start, 100, 5)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('rebuilds only the strip on viewport height changes and invalidates its old callback', () => { + const { viewport, groups: [first] } = fixture() + const { size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + size.notify(viewport) + size.notify(first!.section, 400) + expect(intersections).toHaveLength(2) + size.notify(viewport, 400) + expect(strip.disconnect).toHaveBeenCalledOnce() + const replacement = intersections[2]! + expect(replacement.rootMargin).toBe('0px 0px -399px 0px') + replacement.notify(entry(first!.section, 100, 100, 2)) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 80, 100, 100)) + expect(write).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + size.notify(viewport, 0.5) + expect(intersections[3]!.rootMargin).toBe('0px 0px 0px 0px') + size.notify(viewport, 0) + expect(intersections[4]!.rootMargin).toBe('0px 0px 0px 0px') + }) + + it('ignores unavailable root bounds and unrelated intersection targets', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + strip.notify(entry(first!.section, 100, 100, 2, 1, null), entry(viewport, 100, 100, 2)) + start.notify(entry(first!.start, 100, 1, 2, 200, null), startEntry(viewport, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('disconnects every observer, clears only stuck headings, and prevents all late writes', () => { + const { viewport, groups: [first] } = fixture() + const { dispose, start, size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + const remove = vi.spyOn(first!.heading, 'removeAttribute') + dispose() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(remove).toHaveBeenCalledOnce() + const write = vi.spyOn(first!.heading, 'toggleAttribute') + start.notify(startEntry(first!.start, 80, 2)) + strip.notify(entry(first!.section, 80, 100, 2)) + size.notify(viewport, 400) + dispose() + expect(intersections).toHaveLength(2) + expect(write).not.toHaveBeenCalled() + expect(remove).toHaveBeenCalledOnce() + for (const observer of [start, size, strip]) expect(observer.disconnect).toHaveBeenCalledOnce() + }) + + it('can dispose before the first size delivery without constructing a strip observer', () => { + const { viewport } = fixture() + const dispose = observe(viewport) + dispose() + sizes[0]!.notify(viewport) + expect(intersections).toHaveLength(1) + expect(sizes[0]!.disconnect).toHaveBeenCalledOnce() + }) + + it('recaptures filtered groups and rejects callbacks from the previous setup', () => { + const { viewport, rerender, groups: [first] } = fixture() + const previous = startObserving(viewport) + previous.strip.notify(entry(first!.section, 80, 100, 1)) + previous.dispose() + rerender(
) + const group = screen.getByRole('group', { name: 'Filtered' }) + const heading = directChild(group, 'data-menu-group-heading') + observe(viewport) + sizes[1]!.notify(viewport) + const current = intersections[3]! + expect(current.observe.mock.calls).toEqual([[group]]) + current.notify(entry(group, 80, 100, 2)) + expect(heading.hasAttribute('data-stuck')).toBe(true) + const write = vi.spyOn(heading, 'toggleAttribute') + previous.start.notify(startEntry(first!.start, 100, 3)) + previous.strip.notify(entry(first!.section, 100, 100, 3)) + previous.size.notify(viewport, 400) + expect(write).not.toHaveBeenCalled() + expect(heading.hasAttribute('data-stuck')).toBe(true) + expect(intersections).toHaveLength(4) + }) + + it('observes only direct groups with both direct headings and start sentinels', () => { + render(
+ +
Indirect
+
No start
+
Unmarked
+
) + const viewport = screen.getByTestId('viewport') + const group = screen.getByRole('group', { name: 'Direct' }) + const { start, strip } = startObserving(viewport) + expect(start.observe.mock.calls).toEqual([[directChild(group, 'data-menu-group-start')]]) + expect(strip.observe.mock.calls).toEqual([[group]]) + strip.notify(entry(group, 80, 100, 1)) + expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) + for (const label of ['Nested', 'Indirect', 'No start', 'Unmarked']) { + expect(screen.getByText(label).hasAttribute('data-stuck')).toBe(false) + } + }) + + it('acquires no observers or listeners without valid direct groups', () => { + render(
) const viewport = screen.getByTestId('viewport') const addScroll = vi.spyOn(viewport, 'addEventListener') const addResize = vi.spyOn(window, 'addEventListener') - const ResizeObserverStub = vi.fn() - vi.stubGlobal('ResizeObserver', ResizeObserverStub) const dispose = observe(viewport) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) expect(addScroll).not.toHaveBeenCalled() expect(addResize).not.toHaveBeenCalled() - expect(ResizeObserverStub).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) + + it.each(['IntersectionObserver', 'ResizeObserver'])('keeps CSS sticky headings transparent without %s', (api) => { + vi.stubGlobal(api, undefined) + const { viewport, groups: [first] } = fixture() + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const dispose = observe(viewport) + viewport.scrollTop = 20 + fireEvent.scroll(viewport) + fireEvent.resize(window) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) + expect(read).not.toHaveBeenCalled() expect(dispose).not.toThrow() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 3942b72b1b..5c5c81b7e0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -31,6 +31,7 @@ "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/model-menu-observer.e2e.ts", "apps/web/tests/github-ready-review.e2e.ts", "apps/web/tests/streaming-fence-highlight.e2e.ts", "apps/web/tests/declared-reasoning.e2e.ts",