refactor(ui-user-questions): let CSS wrap option descriptions
This commit is contained in:
1 parent
f3bb36263d
commit
2b6360c1d9
8 files changed
+58
-86
No files matched your search
@@ -596,7 +596,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
if (label === null || description === null) return null
|
||||
const style = getComputedStyle(description)
|
||||
return {
|
||||
sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1,
|
||||
sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
hasDetails: row.querySelector('[data-option-details]') !== null,
|
||||
@@ -605,7 +605,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}))
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows).toEqual(Array.from({ length: 4 }, () => ({
|
||||
sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none',
|
||||
sameLine: true, fontSize: '13px', lineHeight: '20px', hasDetails: false, maskImage: 'none',
|
||||
})))
|
||||
await composer.getByRole('radio', { name: '中(medium)' }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 8d3815c19858ac85
|
||||
zh: 94fc905fa202d334
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
|
||||
en: 02c47a01ae6fd4f4
|
||||
zh: 7adb34361d02bc9d
|
||||
en: e8d6e22798e572cd
|
||||
zh: 26c9657013b014cd
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
|
||||
en: 10975f68ff9c6fcc
|
||||
zh: 92246aadc363f0eb
|
||||
|
||||
@@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo
|
||||
|
||||
### Answering
|
||||
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. CSS wraps option descriptions beside or below their labels, using 13px text with a 20px line height in both positions. Overflow detection adds disclosure actions without changing text widths. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
### Reading a settled question back
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
|
||||
|
||||
### 作答
|
||||
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。CSS 将选项说明排在标签旁或下方,两种位置均使用 13px 字号和 20px 行高。溢出检测会添加全文按钮,但不改变文本宽度。较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
### 回看已结束的问题
|
||||
|
||||
|
||||
@@ -216,7 +216,6 @@
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
--description-action-space: 30px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
@@ -243,10 +242,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.optionWithDescription {
|
||||
padding-right: calc(12px + var(--description-action-space));
|
||||
}
|
||||
|
||||
.optionSelected {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
@@ -324,12 +319,17 @@
|
||||
}
|
||||
|
||||
.optionCopy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optionLine {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
@@ -353,6 +353,8 @@
|
||||
|
||||
.description {
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
@@ -364,14 +366,7 @@
|
||||
}
|
||||
|
||||
.descriptionTruncated {
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
|
||||
.descriptionInline {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px));
|
||||
}
|
||||
|
||||
.descriptionAction {
|
||||
@@ -586,10 +581,6 @@
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.optionWithDescription {
|
||||
padding-right: calc(6px + var(--description-action-space));
|
||||
}
|
||||
|
||||
.descriptionAction {
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
|
||||
useEffect, useId, useMemo, useRef, useState,
|
||||
type ChangeEvent, type FocusEvent, type KeyboardEvent,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -242,68 +242,46 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const draft = drafts[index]!
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
const [questionTruncated, setQuestionTruncated] = useState(false)
|
||||
const [descriptionModes, setDescriptionModes] = useState<Map<number, 'inline' | 'block' | 'truncated'> | null>(null)
|
||||
const [overflow, setOverflow] = useState<{
|
||||
question: typeof question
|
||||
title: boolean
|
||||
descriptions: Set<number>
|
||||
} | null>(null)
|
||||
const questionTruncated = overflow?.question === question && overflow.title
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
if (answerSurface.current !== null) answerSurface.current.scrollTop = 0
|
||||
}, [index])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
const surface = answerSurface.current
|
||||
if (surface === null || minimized) return
|
||||
const title = surface.querySelector<HTMLElement>('[data-question-title]')
|
||||
const rows = [...surface.querySelectorAll<HTMLElement>('[data-option-row]')]
|
||||
const descriptions = [...surface.querySelectorAll<HTMLElement>('[data-option-description]')]
|
||||
const measure = (): void => {
|
||||
if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1)
|
||||
const next = new Map<number, 'inline' | 'block' | 'truncated'>()
|
||||
for (const row of rows) {
|
||||
const description = row.querySelector<HTMLElement>('[data-option-description]')
|
||||
if (description === null) continue
|
||||
const optionIndex = Number(row.dataset.optionIndex)
|
||||
const copy = row.querySelector<HTMLElement>('[data-option-copy]')
|
||||
const line = row.querySelector<HTMLElement>('[data-option-line]')
|
||||
if (copy === null || line === null || copy.clientWidth === 0) {
|
||||
next.set(optionIndex, 'truncated')
|
||||
continue
|
||||
const titleTruncated = title !== null && title.scrollHeight > title.clientHeight + 1
|
||||
const truncated = new Set<number>()
|
||||
for (const description of descriptions) {
|
||||
if (description.scrollWidth > description.clientWidth + 1) {
|
||||
truncated.add(Number(description.dataset.optionIndex))
|
||||
}
|
||||
const actionSpace = parseFloat(getComputedStyle(row).getPropertyValue('--description-action-space'))
|
||||
// The action's reserved width must not make a fitting description appear truncated.
|
||||
const fullWidth = copy.clientWidth + (row.querySelector('[data-option-details]') === null ? 0 : actionSpace)
|
||||
const probe = line.cloneNode(true) as HTMLElement
|
||||
probe.querySelector('[data-option-description]')?.remove()
|
||||
const probeDescription = description.cloneNode(true) as HTMLElement
|
||||
probeDescription.removeAttribute('id')
|
||||
probeDescription.className = clsx(css.descriptionInline)
|
||||
probe.append(probeDescription)
|
||||
probe.style.cssText = 'position:fixed;top:-10000px;left:0;width:max-content;flex-wrap:nowrap;visibility:hidden;pointer-events:none'
|
||||
document.body.append(probe)
|
||||
const inlineWidth = probe.getBoundingClientRect().width
|
||||
probeDescription.className = clsx(css.description)
|
||||
probeDescription.style.width = 'max-content'
|
||||
const blockWidth = probeDescription.getBoundingClientRect().width
|
||||
probe.remove()
|
||||
next.set(optionIndex, inlineWidth <= fullWidth + 1 ? 'inline'
|
||||
: blockWidth <= fullWidth + 1 ? 'block' : 'truncated')
|
||||
}
|
||||
setDescriptionModes(current => current !== null && current.size === next.size
|
||||
&& [...next].every(([optionIndex, mode]) => current.get(optionIndex) === mode) ? current : next)
|
||||
setOverflow(current => current?.question === question && current.title === titleTruncated
|
||||
&& current.descriptions.size === truncated.size
|
||||
&& [...truncated].every(optionIndex => current.descriptions.has(optionIndex))
|
||||
? current : { question, title: titleTruncated, descriptions: truncated })
|
||||
}
|
||||
measure()
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure)
|
||||
if (title !== null) observer?.observe(title)
|
||||
for (const row of rows) {
|
||||
observer?.observe(row)
|
||||
const copy = row.querySelector('[data-option-copy]')
|
||||
if (copy !== null) observer?.observe(copy)
|
||||
}
|
||||
for (const description of descriptions) observer?.observe(description)
|
||||
const fonts = 'fonts' in document ? document.fonts : null
|
||||
fonts?.addEventListener('loadingdone', measure)
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
fonts?.removeEventListener('loadingdone', measure)
|
||||
}
|
||||
}, [index, minimized, question.question, question.options])
|
||||
}, [minimized, question])
|
||||
|
||||
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
|
||||
actions.replace(pending.key, {
|
||||
@@ -598,8 +576,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
const selected = draft.selected.includes(option.label)
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
const hasDescription = option.description !== undefined && option.description.trim() !== ''
|
||||
const descriptionMode = descriptionModes?.get(optionIndex) ?? 'truncated'
|
||||
const showDetails = hasDescription && descriptionMode === 'truncated'
|
||||
const showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex)
|
||||
const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description`
|
||||
return (
|
||||
<div
|
||||
@@ -614,7 +591,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.option, showDetails && css.optionWithDescription)}
|
||||
className={css.option}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
@@ -640,15 +617,13 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
{display.recommended && (
|
||||
<span className={css.badge}>{t('option.recommended')}</span>
|
||||
)}
|
||||
{hasDescription && descriptionMode === 'inline' && (
|
||||
<span id={descriptionId} className={css.descriptionInline} data-option-description>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
{hasDescription && descriptionMode !== 'inline' && (
|
||||
{hasDescription && (
|
||||
<span
|
||||
id={descriptionId}
|
||||
className={clsx(css.description, showDetails && css.descriptionTruncated)}
|
||||
data-option-description
|
||||
data-option-index={optionIndex}
|
||||
>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -16,17 +16,11 @@ function declarations(selector: string): string[] {
|
||||
}
|
||||
|
||||
describe('QuestionComposer compact long-copy layout', () => {
|
||||
it('keeps fitting descriptions in the original option-line typography', () => {
|
||||
expect(declarations('.descriptionInline')).toEqual(expect.arrayContaining([
|
||||
'font-size: 14px',
|
||||
'line-height: 24px',
|
||||
'font-weight: 400',
|
||||
]))
|
||||
})
|
||||
|
||||
it('keeps the option description to one clipped line and fades only truncated text', () => {
|
||||
expect(declarations('.description')).toEqual(expect.arrayContaining([
|
||||
'display: block',
|
||||
'width: max-content',
|
||||
'max-width: 100%',
|
||||
'min-width: 0',
|
||||
'overflow: hidden',
|
||||
'font-size: 13px',
|
||||
@@ -35,7 +29,7 @@ describe('QuestionComposer compact long-copy layout', () => {
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
expect(declarations('.descriptionTruncated')).toContain(
|
||||
'mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent)',
|
||||
'mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px))',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -327,7 +327,11 @@ describe('QuestionComposer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('opens a full option description without choosing the option', () => {
|
||||
it('opens a full option description without choosing the option', ({ onTestFinished }) => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.hasAttribute('data-option-description') ? 500 : 0
|
||||
})
|
||||
onTestFinished(() => { cleanup(); width.mockRestore() })
|
||||
const description = 'Remove the experimental client from every shipped template and require an explicit profile install.'
|
||||
const { carrier } = wait([{
|
||||
id: 'release', question: 'How should the release graph change?',
|
||||
@@ -356,7 +360,11 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getAllByText(description)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resets scrolling and an open option description when the question changes', () => {
|
||||
it('resets scrolling and an open option description when the question changes', ({ onTestFinished }) => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.hasAttribute('data-option-description') ? 500 : 0
|
||||
})
|
||||
onTestFinished(() => { cleanup(); width.mockRestore() })
|
||||
const firstDescription = 'First question detail that was pinned open.'
|
||||
const secondDescription = 'Second question detail starts closed.'
|
||||
const { carrier } = wait([
|
||||
@@ -959,7 +967,11 @@ function reviewCard(review: readonly AskUserQuestionAnswerItem[] = RECORDED) {
|
||||
}
|
||||
|
||||
describe('review card', () => {
|
||||
it('walks a settled call record with every answer surface frozen', () => {
|
||||
it('walks a settled call record with every answer surface frozen', ({ onTestFinished }) => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.hasAttribute('data-option-description') ? 500 : 0
|
||||
})
|
||||
onTestFinished(() => { cleanup(); width.mockRestore() })
|
||||
const { carrier } = reviewCard()
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
|
||||
Reference in new issue
Block a user