fix(web): keep model label menus inside the viewport
This commit is contained in:
1 parent
919aa5f400
commit
c78f3fd33d
5 files changed
+49
-42
No files matched your search
@@ -104,7 +104,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
provider: START_ROUTE, model: START_MODEL, name: 'Origin Large',
|
||||
slot: 'conversation.input.model.label', inline: true,
|
||||
}
|
||||
expect(await readLabels(trigger)).toEqual([current])
|
||||
await expect.poll(() => readLabels(trigger)).toEqual([current])
|
||||
try {
|
||||
await trigger.click()
|
||||
const cell = page.getByRole('menuitem', { name: /^模型/ })
|
||||
@@ -122,6 +122,40 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['root', 'model'])('reanchors the %s menu when a label grows and shrinks without a window resize', async (pane) => {
|
||||
const viewport = page.viewportSize()!
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await trigger.click()
|
||||
if (pane === 'model') await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const surface = page.getByRole(pane === 'model' ? 'group' : 'menu', { name: '模型与推理等级', exact: true })
|
||||
const label = surface.locator('[data-provider-id="origin-gateway"][data-model-id="origin-large"]')
|
||||
await label.waitFor()
|
||||
const original = await surface.boundingBox()
|
||||
expect(original).not.toBeNull()
|
||||
// A Slot descendant can resize independently of ModelSelect's props and state.
|
||||
await label.evaluate((node) => {
|
||||
const badge = node.ownerDocument.createElement('span')
|
||||
badge.dataset.modelLabelResizeProbe = ''
|
||||
badge.textContent = ' — Provider identification badge with a long channel name'
|
||||
node.appendChild(badge)
|
||||
})
|
||||
await expect.poll(async () => (await surface.boundingBox())!.width).toBeGreaterThan(original!.width + 50)
|
||||
await expect.poll(async () => {
|
||||
const box = (await surface.boundingBox())!
|
||||
return box.x >= 12 && box.x + box.width <= 378
|
||||
}).toBe(true)
|
||||
await label.locator('[data-model-label-resize-probe]').evaluate((node) => { node.remove() })
|
||||
await expect.poll(async () => (await surface.boundingBox())!.width).toBeCloseTo(original!.width)
|
||||
await expect.poll(async () => (await surface.boundingBox())!.x).toBeCloseTo(original!.x)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps command popup search borders transparent in both palettes', async () => {
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 48f8157ffdfe6e69
|
||||
zh: 64ec9a533e2aa8f4
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions:
|
||||
en: 615e9ab1b07d139c
|
||||
zh: 75e9192dc0244ffd
|
||||
en: bc47871cf8b61451
|
||||
zh: 17d21da46604f071
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions:
|
||||
en: 7f20428c747ff052
|
||||
zh: 975e6de728e02fa0
|
||||
|
||||
@@ -49,7 +49,7 @@ The composer replaces the model and effort text with the Models icon when the ex
|
||||
|
||||
`conversation.input.model.label` is a session-scoped, inline `single` slot for the composer trigger (`placement: 'trigger'`), the root menu's current value (`'menu'`), and each model option (`'option'`). Its owner props provide `provider: { id, name }`, the exact catalog `model`, `placement`, and `selected`. Provider names are localized display names; match channels by `provider.id`, and identify a model by the provider/model ID pair. Connection URLs and credentials are not passed to the slot.
|
||||
|
||||
Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name. Loading and unavailable selections do not call the slot. `/model` command rows use the command popup's separate rendering API.
|
||||
Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name. The trigger and root-menu value use the slot only when the selected model is present in the displayed catalog; otherwise they keep the built-in placeholder. During refresh, retained catalog entries continue to use the slot. Open menus track label size changes to stay inside the viewport. `/model` command rows use the command popup's separate rendering API.
|
||||
|
||||
Import this package's `/client` declarations type-only, then register through `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))`. Derive `ModelLabel` props from `PropsRuntime<'conversation.input.model.label'>`; use provider IDs to choose CSS Module classes and retain `model.name` as the text. Registration follows the declaration and contributing plugin lifetimes. Without a component replacement, styles can target the enclosing label's `data-provider-id` and `data-model-id` attributes in all three placements.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
|
||||
|
||||
`conversation.input.model.label` 是会话作用域的内联 `single` slot,覆盖 composer 触发按钮(`placement: 'trigger'`)、根菜单中的当前值(`'menu'`)和各模型选项(`'option'`)。owner props 提供 `provider: { id, name }`、准确的目录 `model`、`placement` 和 `selected`。提供方名称是本地化显示名称;按 `provider.id` 匹配渠道,按提供方与模型 ID 的组合识别模型。slot 不接收连接 URL 或凭据。
|
||||
|
||||
扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称。加载中或当前选择不可用时不调用 slot。`/model` 命令行使用命令弹窗独立的渲染 API。
|
||||
扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称。触发按钮和根菜单当前值仅在所选模型存在于显示目录中时调用 slot,否则保留内置占位文字。刷新期间保留的目录条目仍调用 slot。菜单打开时会跟随名称内容的尺寸变化调整位置,保持在视口内。`/model` 命令行使用命令弹窗独立的渲染 API。
|
||||
|
||||
以 type-only 方式导入本包的 `/client` 声明,再通过 `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))` 注册。`ModelLabel` 的 props 从 `PropsRuntime<'conversation.input.model.label'>` 派生;用提供方 ID 选择 CSS Module 类,并保留 `model.name` 作为文字。注册随声明和贡献插件的生命周期清理。不替换组件时,样式也可以在三个显示位置通过名称外层的 `data-provider-id` 和 `data-model-id` 属性定位。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* chevron, and each row whose value that selection carries shows one in place
|
||||
* of its check mark.
|
||||
*/
|
||||
import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MenuGroup, MenuSurface, observeStickyMenuGroups, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
|
||||
type CSSProperties, type KeyboardEvent, type FocusEvent,
|
||||
@@ -87,7 +87,15 @@ export function ModelSelect(
|
||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||
const groupsRef = useRef<HTMLDivElement | null>(null)
|
||||
const [menuPos, setMenuPos] = useState<CSSProperties | null>(null)
|
||||
const menuPos = useAnchoredPosition({
|
||||
open: available && open,
|
||||
anchorRef: triggerRef,
|
||||
panelRef: menuRef,
|
||||
side: 'top',
|
||||
align: 'end',
|
||||
gap: 8,
|
||||
margin: 12,
|
||||
})
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const id = useId()
|
||||
|
||||
@@ -206,40 +214,6 @@ export function ModelSelect(
|
||||
}
|
||||
}, [open, pane, activeModelIndex, visibleModels])
|
||||
|
||||
// Portaled placement (the Menu primitive's portal rules: fixed from the
|
||||
// anchor rect, measured before paint, clamped inside the viewport): above
|
||||
// the trigger, right edges aligned. Depends on pane and directory state
|
||||
// because pane switches and async catalog loads resize the card.
|
||||
/* jscpd:ignore-start -- deliberate mirror of ui-primitives useAnchoredPosition:
|
||||
that hook only places from the anchor's LEFT edge, while this card aligns
|
||||
right edges (x = rect.right - width), so the measure-and-clamp plumbing repeats. */
|
||||
useLayoutEffect(() => {
|
||||
if (!open) { setMenuPos(null); return }
|
||||
const place = (): void => {
|
||||
/* v8 ignore next 2 -- the trigger ref is attached whenever the menu is open. */
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (rect === undefined) return
|
||||
const MARGIN = 12
|
||||
const lw = menuRef.current?.offsetWidth ?? 0
|
||||
const lh = menuRef.current?.offsetHeight ?? 0
|
||||
let x = rect.right - lw
|
||||
let y = rect.top - 8 - lh
|
||||
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
|
||||
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
|
||||
setMenuPos({ left: x, top: y })
|
||||
}
|
||||
// First run measures the hidden pre-render (same commit as `open`), so
|
||||
// the card lands placed before anything paints.
|
||||
place()
|
||||
window.addEventListener('scroll', place, true)
|
||||
window.addEventListener('resize', place)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', place, true)
|
||||
window.removeEventListener('resize', place)
|
||||
}
|
||||
}, [open, pane, state, query])
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
if (!available) return null
|
||||
|
||||
const show = (): void => {
|
||||
@@ -438,7 +412,6 @@ export function ModelSelect(
|
||||
}, { fallback: model.name })}
|
||||
</span>
|
||||
)
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
let modelIndex = 0
|
||||
const itemRef = () => {
|
||||
|
||||
Reference in new issue
Block a user