fix(client): fade clipped model selector text instead of ellipsizing
Model and effort text in the composer trigger, root menu values, and option rows now clips and fades over its last 24px while it overflows, following the measured-clip mask used by dockkit tab titles and PathLabel.
This commit is contained in:
1 parent
a6aaac3ce6
commit
652747423c
12 files changed
+138
-43
No files matched your search
@@ -69,21 +69,23 @@ describe.skipIf(MODE === 'record')('web e2e: temporary DeepSeek release badge',
|
||||
}
|
||||
})
|
||||
|
||||
it('ellipsizes the inline release with the model name and restores it after widening', async () => {
|
||||
it('fades out the inline release with the model name and restores it after widening', async () => {
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const readLayout = () => trigger.getByText('1017', { exact: true }).evaluate((release) => {
|
||||
const label = release.parentElement!
|
||||
const container = label.closest('[data-provider-id]')!.parentElement!
|
||||
const box = label.closest('[data-model-select-text]')!
|
||||
const style = getComputedStyle(release)
|
||||
const labelStyle = getComputedStyle(label)
|
||||
const boxStyle = getComputedStyle(box)
|
||||
return {
|
||||
clipped: container.scrollWidth > container.clientWidth,
|
||||
ellipsis: getComputedStyle(container).textOverflow,
|
||||
overflowing: box.scrollWidth > box.clientWidth,
|
||||
clipped: box.hasAttribute('data-model-select-clipped'),
|
||||
textOverflow: boxStyle.textOverflow,
|
||||
fade: boxStyle.maskImage,
|
||||
inline: labelStyle.display === 'inline' && style.display === 'inline',
|
||||
sameFontSize: style.fontSize === labelStyle.fontSize,
|
||||
background: style.backgroundColor,
|
||||
padding: style.padding,
|
||||
mask: labelStyle.maskImage,
|
||||
}
|
||||
})
|
||||
try {
|
||||
@@ -91,8 +93,9 @@ describe.skipIf(MODE === 'record')('web e2e: temporary DeepSeek release badge',
|
||||
await expect.poll(async () => (await readLayout()).clipped).toBe(true)
|
||||
const narrowed = await readLayout()
|
||||
expect(narrowed).toEqual({
|
||||
clipped: true, ellipsis: 'ellipsis', inline: true, sameFontSize: true,
|
||||
background: 'rgba(0, 0, 0, 0)', padding: '0px', mask: 'none',
|
||||
overflowing: true, clipped: true, textOverflow: 'clip',
|
||||
fade: 'linear-gradient(90deg, rgb(0, 0, 0) calc(100% - 24px), rgba(0, 0, 0, 0))',
|
||||
inline: true, sameFontSize: true, background: 'rgba(0, 0, 0, 0)', padding: '0px',
|
||||
})
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect.poll(async () => (await readLayout()).clipped).toBe(false)
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"narrowed": {
|
||||
"overflowing": true,
|
||||
"clipped": true,
|
||||
"ellipsis": "ellipsis",
|
||||
"textOverflow": "clip",
|
||||
"fade": "linear-gradient(90deg, rgb(0, 0, 0) calc(100% - 24px), rgba(0, 0, 0, 0))",
|
||||
"inline": true,
|
||||
"sameFontSize": true,
|
||||
"background": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"mask": "none"
|
||||
"padding": "0px"
|
||||
},
|
||||
"widened": {
|
||||
"overflowing": false,
|
||||
"clipped": false,
|
||||
"ellipsis": "ellipsis",
|
||||
"textOverflow": "clip",
|
||||
"fade": "none",
|
||||
"inline": true,
|
||||
"sameFontSize": true,
|
||||
"background": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"mask": "none"
|
||||
"padding": "0px"
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-deepseek-model-label-1017/use-this-package:
|
||||
en: 7f3b996dac34d01b
|
||||
zh: 62021ac2bab65b39
|
||||
en: c241d810553094d8
|
||||
zh: 9102b83b2aa5e032
|
||||
/deepseek-ai-dsh-client-ui-deepseek-model-label-1017/understand-the-implementation:
|
||||
en: 368aae60ad92d90e
|
||||
zh: 67ac74f0351f1b5e
|
||||
|
||||
@@ -27,7 +27,7 @@ This package shows `1017` beside `DeepSeek-V4.1-Flash` in the composer model sel
|
||||
|
||||
The Web bundle includes this plugin, including when Desktop uses that bundle. It has no configuration fields.
|
||||
|
||||
The badge appears only when the provider is `deepseek-official` or `deepseek-account`, the model ID is `deepseek-flash`, and the catalog name is exactly `DeepSeek-V4.1-Flash`. Other names and providers keep their catalog text. The input button, current-model menu row, and model options share the blue plain-text release marker. It uses the model name’s font size and baseline, with no capsule or fade; limited space ellipsizes the combined text, so the marker may be hidden.
|
||||
The badge appears only when the provider is `deepseek-official` or `deepseek-account`, the model ID is `deepseek-flash`, and the catalog name is exactly `DeepSeek-V4.1-Flash`. Other names and providers keep their catalog text. The input button, current-model menu row, and model options share the blue plain-text release marker. It uses the model name’s font size and baseline, with no capsule; when space is limited, the selector fades out the end of the combined text, so the marker may be hidden.
|
||||
|
||||
Disable the following row through a profile patch to restore the catalog name:
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
Web bundle 包含本插件,桌面端使用该 bundle 时也会加载。本包没有配置字段。
|
||||
|
||||
只有提供方为 `deepseek-official` 或 `deepseek-account`、模型 ID 为 `deepseek-flash`,且目录名称恰好为 `DeepSeek-V4.1-Flash` 时才显示角标。其他名称和提供方保留目录文字。输入框按钮、当前模型菜单行和模型选项共用蓝色纯文字版本标记。标记与模型名称字号相同、基线对齐,不使用胶囊或渐隐;空间不足时整段文字显示省略号,版本标记可能被隐藏。
|
||||
只有提供方为 `deepseek-official` 或 `deepseek-account`、模型 ID 为 `deepseek-flash`,且目录名称恰好为 `DeepSeek-V4.1-Flash` 时才显示角标。其他名称和提供方保留目录文字。输入框按钮、当前模型菜单行和模型选项共用蓝色纯文字版本标记。标记与模型名称字号相同、基线对齐,不使用胶囊;空间不足时由选择器将整段文字末尾渐隐,版本标记可能被隐藏。
|
||||
|
||||
通过 profile patch 停用以下条目即可恢复目录名称:
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: ef50e68e7493d0a9
|
||||
zh: 3b1faea17b606173
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
|
||||
en: 48f8157ffdfe6e69
|
||||
zh: 64ec9a533e2aa8f4
|
||||
en: 9c6fb87fed391f94
|
||||
zh: 4cd7ef4e6a948fe1
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions:
|
||||
en: bc47871cf8b61451
|
||||
zh: 17d21da46604f071
|
||||
|
||||
@@ -43,7 +43,7 @@ Model and reasoning-effort names use weight 400 (regular) in the composer menu,
|
||||
|
||||
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.
|
||||
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. Model and effort text that does not fit its space, in the trigger, the root menu values, and the option rows, is clipped and fades out over its last 24px instead of ending in an ellipsis.
|
||||
|
||||
### Model label extensions
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
|
||||
|
||||
两个入口均按提供方分组,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 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。
|
||||
展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。触发按钮、根菜单当前值和选项行中放不下的模型与推理强度文字会被裁切,并在末尾 24px 内渐隐,不显示省略号。
|
||||
|
||||
### 模型名称扩展
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* One-line selector text that fades out at its clipped end instead of showing
|
||||
* an ellipsis. While the inner run is wider than the box, the box carries
|
||||
* `data-model-select-clipped` and the stylesheet masks its last 24px. Both
|
||||
* spans are observed, so a narrowed composer, a loaded font, and a label slot
|
||||
* that changes its own content all re-read the geometry.
|
||||
*/
|
||||
import { useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './ModelSelect.module.css'
|
||||
|
||||
/**
|
||||
* Render clipped text with an end fade.
|
||||
* @param props - Placement class for the box and the text it holds.
|
||||
* @returns The fading box around an unconstrained text run.
|
||||
*/
|
||||
export function FadeText({ className, children }: { className: string | undefined; children: ReactNode }) {
|
||||
const box = useRef<HTMLSpanElement>(null)
|
||||
const run = useRef<HTMLSpanElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
// Both spans mount unconditionally before this layout effect runs.
|
||||
const outer = box.current as HTMLSpanElement
|
||||
const inner = run.current as HTMLSpanElement
|
||||
const mark = (): void => {
|
||||
outer.toggleAttribute('data-model-select-clipped', inner.offsetWidth > outer.clientWidth)
|
||||
}
|
||||
mark()
|
||||
if (typeof ResizeObserver === 'undefined') return undefined
|
||||
const observer = new ResizeObserver(mark)
|
||||
observer.observe(outer)
|
||||
observer.observe(inner)
|
||||
return () => { observer.disconnect() }
|
||||
}, [])
|
||||
return <span ref={box} className={clsx(css.fade, className)} data-model-select-text>
|
||||
<span ref={run} className={css.fadeRun}>{children}</span>
|
||||
</span>
|
||||
}
|
||||
@@ -44,23 +44,34 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Truncating text (FadeText) clips without an ellipsis and, while clipped,
|
||||
fades over its last 24px. A mask rather than a painted gradient, so the
|
||||
fade matches the trigger's bare and hover fills and every menu row. */
|
||||
.fade {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fade[data-model-select-clipped] {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
/* Inline-block so its width is the text's own and resizes with it. */
|
||||
.fadeRun {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Effort value beside the model name (mock's 'High': same 13/20/400, caption
|
||||
tone). Adaptive priority: the huge shrink factor makes the effort absorb
|
||||
all deficit first — it ellipsizes away to nothing before the model name
|
||||
all deficit first — it fades away to nothing before the model name
|
||||
loses a single pixel, and the chevron stays snug throughout. */
|
||||
.triggerEffort {
|
||||
flex-shrink: 1000;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@@ -96,7 +107,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Let the visible pane's rows set the card width, but keep pathological ids
|
||||
and narrow screens inside a bounded card where ellipsis remains. */
|
||||
and narrow screens inside a bounded card where clipped text fades. */
|
||||
width: max-content;
|
||||
min-width: min(240px, calc(100vw - 32px));
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
@@ -274,13 +285,10 @@
|
||||
}
|
||||
|
||||
.modelName {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
@@ -334,9 +342,6 @@
|
||||
.cellValue {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelLabelOwner, ModelSelectInjected } from './slots.ts'
|
||||
import { FadeText } from './FadeText.tsx'
|
||||
import css from './ModelSelect.module.css'
|
||||
import { orderModelProviders } from './provider-order.ts'
|
||||
|
||||
@@ -482,10 +483,10 @@ export function ModelSelect(
|
||||
}}
|
||||
>
|
||||
<IconDataOutlineRegular className={css.triggerIcon} size={16} />
|
||||
<span className={css.triggerLabel}>
|
||||
<FadeText className={css.triggerLabel}>
|
||||
{currentChoice === undefined ? modelLabel : renderModelLabel(currentChoice.group.provider, currentChoice.model, 'trigger', true)}
|
||||
</span>
|
||||
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
|
||||
</FadeText>
|
||||
{effortLabel !== undefined && <FadeText className={css.triggerEffort}>{effortLabel}</FadeText>}
|
||||
{busy
|
||||
? <StateDot state="ongoing" />
|
||||
: <IconChevronDownOutlineRegular className={clsx(css.chevron, open && css.chevronOpen)} />}
|
||||
@@ -508,15 +509,15 @@ export function ModelSelect(
|
||||
<>
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('model') }}>
|
||||
<span className={css.cellLabel}>{t('menu.model')}</span>
|
||||
<span className={css.cellValue}>
|
||||
<FadeText className={css.cellValue}>
|
||||
{currentChoice === undefined ? modelLabel : renderModelLabel(currentChoice.group.provider, currentChoice.model, 'menu', true)}
|
||||
</span>
|
||||
</FadeText>
|
||||
<IconChevronRightOutlineRegular className={css.cellChevron} />
|
||||
</button>
|
||||
{reasoning !== undefined && (
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('effort') }}>
|
||||
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
||||
<span className={css.cellValue}>{effortLabel}</span>
|
||||
<FadeText className={css.cellValue}>{effortLabel}</FadeText>
|
||||
<IconChevronRightOutlineRegular className={css.cellChevron} />
|
||||
</button>
|
||||
)}
|
||||
@@ -606,7 +607,7 @@ export function ModelSelect(
|
||||
onClick={() => { choose({ provider: group.id, model: model.id }) }}
|
||||
>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.modelName}>{renderModelLabel(group.provider, model, 'option', selected)}</span>
|
||||
<FadeText className={css.modelName}>{renderModelLabel(group.provider, model, 'option', selected)}</FadeText>
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{pending?.provider === group.id && pending.model === model.id
|
||||
@@ -648,7 +649,7 @@ export function ModelSelect(
|
||||
onClick={() => { chooseEffort(level.effort) }}
|
||||
>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.modelName}>{level.label}</span>
|
||||
<FadeText className={css.modelName}>{level.label}</FadeText>
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{pending !== null && pending.provider === state.current?.provider
|
||||
|
||||
@@ -146,6 +146,53 @@ describe('ModelSelect label slot', () => {
|
||||
expect(screen.getByRole('button', { name: '请选择模型' })).toBeTruthy()
|
||||
expect(renderSlot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const renderSeat = () => render(
|
||||
<ModelSelect locked={false} available directory={createSnapshotStore(state())}
|
||||
load={vi.fn()} select={vi.fn()} t={t} renderSlot={renderLabel} />,
|
||||
)
|
||||
|
||||
it('marks truncated text clipped while it is wider than its box, re-reading on resize', () => {
|
||||
const observers: Array<{ targets: Element[]; fire: () => void; disconnect: ReturnType<typeof vi.fn> }> = []
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
readonly targets: Element[] = []
|
||||
readonly disconnect = vi.fn()
|
||||
constructor(callback: () => void) {
|
||||
observers.push({ targets: this.targets, fire: callback, disconnect: this.disconnect })
|
||||
}
|
||||
observe(target: Element) { this.targets.push(target) }
|
||||
unobserve() {}
|
||||
})
|
||||
onTestFinished(() => { vi.unstubAllGlobals() })
|
||||
let boxWidth = 300
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(200)
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => boxWidth)
|
||||
onTestFinished(() => { vi.restoreAllMocks() })
|
||||
const view = renderSeat()
|
||||
const label = screen.getByRole('button', { name: /^选择模型/ }).querySelector<HTMLElement>('[data-model-select-text]')!
|
||||
const observer = observers.find(entry => entry.targets[0] === label)!
|
||||
expect(observer.targets).toEqual([label, label.firstElementChild])
|
||||
expect(label.hasAttribute('data-model-select-clipped')).toBe(false)
|
||||
boxWidth = 120
|
||||
act(() => { observer.fire() })
|
||||
expect(label.hasAttribute('data-model-select-clipped')).toBe(true)
|
||||
boxWidth = 300
|
||||
act(() => { observer.fire() })
|
||||
expect(label.hasAttribute('data-model-select-clipped')).toBe(false)
|
||||
view.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks clipping once at mount without ResizeObserver', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
onTestFinished(() => { vi.unstubAllGlobals() })
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(200)
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(120)
|
||||
onTestFinished(() => { vi.restoreAllMocks() })
|
||||
renderSeat()
|
||||
const label = screen.getByRole('button', { name: /^选择模型/ }).querySelector('[data-model-select-text]')!
|
||||
expect(label.hasAttribute('data-model-select-clipped')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelSelect reasoning effort', () => {
|
||||
|
||||
Reference in new issue
Block a user