style(ui-user-questions): expand long copy with chevron icon buttons
The expand and collapse controls reuse the composer's 24px icon button with the existing downward and upward chevron icons instead of text. Each carries a localized accessible name, which a portaled Tooltip shows on hover. The fade at the end of clipped copy narrows to the 24px button.
This commit is contained in:
1 parent
82b43da3dc
commit
012bfe03e2
8 files changed
+99
-96
No files matched your search
@@ -70,12 +70,12 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls:
|
||||
|
||||
/**
|
||||
* Read how the composer draws one long-copy element: the lines it shows,
|
||||
* whether CSS clipped it, whether its text overflows the line budget, and the
|
||||
* label of its toggle while that toggle is visible.
|
||||
* whether CSS clipped it, whether its text overflows the line budget, and what
|
||||
* its toggle does while that toggle is visible.
|
||||
* @param element - a question heading or an option description.
|
||||
* @returns the drawn state of the copy.
|
||||
*/
|
||||
function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: string | undefined } {
|
||||
function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } {
|
||||
const style = getComputedStyle(element)
|
||||
const toggle = element.tagName === 'H2'
|
||||
? element.nextElementSibling
|
||||
@@ -86,7 +86,7 @@ function readCopy(element: HTMLElement): { lines: number; clipped: boolean; over
|
||||
clipped: style.maskImage !== 'none',
|
||||
overflows: element.scrollHeight > element.clientHeight + 0.5,
|
||||
toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible'
|
||||
? toggle.textContent ?? undefined
|
||||
? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand'
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
@@ -414,7 +414,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const copy = await description.evaluate(readCopy)
|
||||
expect(copy.lines).toBeLessThanOrEqual(2)
|
||||
expect(copy.clipped).toBe(copy.overflows)
|
||||
expect(copy.toggle).toBe(copy.clipped ? 'More' : undefined)
|
||||
expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
|
||||
}
|
||||
scrollStates.push(squeeze.scrolls)
|
||||
expect(squeeze.titleInScrollport).toBe(true)
|
||||
@@ -432,14 +432,14 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
const blueText = composer.locator('[data-option-description]').first()
|
||||
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await blueToggle.click()
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' })
|
||||
expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
await blueToggle.click()
|
||||
// A collapsed copy's scroll timeline reports the overflow from the next frame.
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize(originalViewport)
|
||||
|
||||
// Multi-line custom answer: the field is a textarea whose hidden mirror
|
||||
@@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor()
|
||||
expect(await composer.getByRole('heading').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(await composer.getByRole('button', { name: 'More' }).count()).toBe(0)
|
||||
expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0)
|
||||
const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => {
|
||||
const label = row.querySelector<HTMLElement>('[data-option-line] > span:first-child')
|
||||
const description = row.querySelector<HTMLElement>('[data-option-description]')
|
||||
@@ -726,15 +726,15 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
// Past its three-line budget the question keeps two lines; past its
|
||||
// two-line budget a description keeps one. Copy within budget shows in full.
|
||||
await expect.poll(() => heading.evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' })
|
||||
.toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Keep the default edge').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
for (const copy of await composer.locator('[data-option-description]').all()) {
|
||||
const drawn = await copy.evaluate(readCopy)
|
||||
expect(drawn.clipped).toBe(drawn.overflows)
|
||||
expect(drawn.toggle).toBe(drawn.clipped ? 'More' : undefined)
|
||||
expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined)
|
||||
expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2)
|
||||
}
|
||||
if (viewport.width === 512) {
|
||||
@@ -758,15 +758,17 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
// scrollport while navigation and submission stay reachable.
|
||||
const questionToggle = await toggleFor(heading)
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question')
|
||||
await questionToggle.click()
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question')
|
||||
const expandedQuestion = await heading.evaluate(readCopy)
|
||||
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' })
|
||||
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedQuestion.lines).toBeGreaterThan(3)
|
||||
expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true)
|
||||
expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true })
|
||||
await questionToggle.click()
|
||||
await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' })
|
||||
await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
const detailText = composer.getByText(detail, { exact: true })
|
||||
expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true)
|
||||
@@ -782,10 +784,11 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const descriptionId = await explicitText.getAttribute('id')
|
||||
expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”')
|
||||
await descriptionToggle.click()
|
||||
expect(await explicit.getAttribute('aria-checked')).toBe('false')
|
||||
const expandedDescription = await explicitText.evaluate(readCopy)
|
||||
expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' })
|
||||
expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedDescription.lines).toBeGreaterThan(2)
|
||||
expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true)
|
||||
expect(await explicitText.evaluate((text) => {
|
||||
@@ -794,7 +797,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5)
|
||||
})).toBe(true)
|
||||
|
||||
// The toggle's ink stays readable on the card in both themes.
|
||||
// The toggle's icon keeps non-text contrast on the card in both themes.
|
||||
const palette: string[] = []
|
||||
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
@@ -814,7 +817,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const foreground = luminance(colors.text)
|
||||
const background = luminance(colors.surface)
|
||||
expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05))
|
||||
.toBeGreaterThanOrEqual(4.5)
|
||||
.toBeGreaterThanOrEqual(3)
|
||||
palette.push(colors.surface)
|
||||
}
|
||||
expect(palette[0]).not.toBe(palette[1])
|
||||
@@ -826,7 +829,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}
|
||||
await descriptionToggle.click()
|
||||
await expect.poll(() => explicitText.evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await composer.getByRole('checkbox', { name: 'Explicit install' }).click()
|
||||
const answer = composer.getByRole('textbox')
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 8d3815c19858ac85
|
||||
zh: 94fc905fa202d334
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
|
||||
en: fda2aac4a93d6550
|
||||
zh: aa5ba8322b566f37
|
||||
en: 2173107a23e9f92a
|
||||
zh: d3a6d17e50dd4b51
|
||||
/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. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and offers **More** there, which expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and **More**. Expanded copy keeps **Show less** on a line below it. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "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. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "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 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并提供「全部」,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并提供「全部」。展开后的文字下方单独一行显示「收起」。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
### 回看已结束的问题
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
/* Long copy keeps one line fewer than its budget once it overflows: a
|
||||
question shows in full up to three lines, an option description up to two;
|
||||
longer copy drops its last line, fades at the end of the line before, and
|
||||
shows its toggle there. Each box keeps its full budget, plus a 2px
|
||||
shows its expand button there. Each box keeps its full budget, plus a 2px
|
||||
allowance for glyphs taller than the line, so the scroll timeline that
|
||||
reports the overflow never depends on the collapsed layout. A negative
|
||||
bottom margin, not a smaller box, gives the dropped line back to the row. */
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.questionTitle[data-expanded] {
|
||||
padding-bottom: 22px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.titlePreview {
|
||||
@@ -142,7 +142,7 @@
|
||||
@keyframes question-clipped {
|
||||
from, to {
|
||||
margin-bottom: calc(-1lh - 2px);
|
||||
mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px));
|
||||
mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
|
||||
mask-size: 100% 1lh, 100% 1lh;
|
||||
mask-position: 0 0, 0 1lh;
|
||||
mask-repeat: no-repeat;
|
||||
@@ -151,8 +151,8 @@
|
||||
|
||||
.questionToggle {
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
bottom: 0;
|
||||
right: -4px;
|
||||
bottom: -1px;
|
||||
animation-name: copy-toggle-shown;
|
||||
animation-fill-mode: both;
|
||||
animation-timeline: --question-copy;
|
||||
@@ -400,7 +400,7 @@
|
||||
@keyframes description-clipped {
|
||||
from, to {
|
||||
margin-bottom: calc(-1lh - 2px);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px));
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
|
||||
mask-size: 100% 1lh;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
@@ -408,16 +408,16 @@
|
||||
|
||||
.descriptionToggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 7px;
|
||||
right: 8px;
|
||||
bottom: 6px;
|
||||
animation-name: copy-toggle-shown;
|
||||
animation-fill-mode: both;
|
||||
animation-timeline: --description-copy;
|
||||
}
|
||||
|
||||
/* Expanded copy keeps its toggle on a line of its own below the text. */
|
||||
/* Expanded copy keeps its collapse button on a line of its own below the text. */
|
||||
.optionRow[data-expanded] .option {
|
||||
padding-bottom: 30px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.questionTitle[data-expanded] .titlePreview,
|
||||
@@ -426,18 +426,8 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */
|
||||
.copyToggle {
|
||||
padding: 1px 6px;
|
||||
border: 0;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -453,13 +443,6 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.copyToggle:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
.titlePreview,
|
||||
@@ -640,7 +623,7 @@
|
||||
}
|
||||
|
||||
.descriptionToggle {
|
||||
right: 0;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -6,7 +6,7 @@ import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular,
|
||||
IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
|
||||
IconEditOutlineRegular, MarkdownText,
|
||||
IconEditOutlineRegular, MarkdownText, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts'
|
||||
import type { PendingQuestion } from './contract/slots.ts'
|
||||
@@ -250,6 +250,8 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
// CSS decides which copy is clipped; only the user's expansions are state.
|
||||
const [expanded, setExpanded] = useState<ExpandedCopy>(NOTHING_EXPANDED)
|
||||
|
||||
const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand')
|
||||
|
||||
const toggleCopy = (copy: 'question' | number): void => {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current)
|
||||
@@ -524,15 +526,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
>
|
||||
{question.question}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.copyToggle, css.questionToggle)}
|
||||
aria-expanded={expanded.has('question')}
|
||||
aria-controls={`question-${pending.key}-${String(index)}`}
|
||||
onClick={() => { toggleCopy('question') }}
|
||||
>
|
||||
{t(expanded.has('question') ? 'text.collapse' : 'text.expand')}
|
||||
</button>
|
||||
<Tooltip label={questionToggleLabel} side="top" portal>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.copyToggle, css.questionToggle)}
|
||||
aria-label={questionToggleLabel}
|
||||
aria-expanded={expanded.has('question')}
|
||||
aria-controls={`question-${pending.key}-${String(index)}`}
|
||||
onClick={() => { toggleCopy('question') }}
|
||||
>
|
||||
{expanded.has('question') ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{question.detail !== undefined && (
|
||||
<div className={css.detail}><MarkdownText text={question.detail} labels={markdownLabels} /></div>
|
||||
@@ -543,6 +548,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
const hasDescription = option.description !== undefined && option.description.trim() !== ''
|
||||
const descriptionExpanded = expanded.has(optionIndex)
|
||||
const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label })
|
||||
const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description`
|
||||
return (
|
||||
<div
|
||||
@@ -592,16 +598,19 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
</span>
|
||||
</button>
|
||||
{hasDescription && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.copyToggle, css.descriptionToggle)}
|
||||
aria-expanded={descriptionExpanded}
|
||||
aria-controls={descriptionId}
|
||||
aria-describedby={descriptionId}
|
||||
onClick={() => { toggleCopy(optionIndex) }}
|
||||
>
|
||||
{t(descriptionExpanded ? 'text.collapse' : 'text.expand')}
|
||||
</button>
|
||||
<Tooltip label={descriptionToggleLabel} side="top" portal>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.copyToggle, css.descriptionToggle)}
|
||||
aria-label={descriptionToggleLabel}
|
||||
aria-expanded={descriptionExpanded}
|
||||
aria-controls={descriptionId}
|
||||
aria-describedby={descriptionId}
|
||||
onClick={() => { toggleCopy(optionIndex) }}
|
||||
>
|
||||
{descriptionExpanded ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,8 +25,10 @@ export const zh = {
|
||||
'nav.maximize': '展开问题卡片',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'nav.close': '收起问题面板,可从工具调用重新打开',
|
||||
'text.expand': '全部',
|
||||
'text.collapse': '收起',
|
||||
'question.expand': '展开完整问题',
|
||||
'question.collapse': '收起问题',
|
||||
'option.expand': '展开“{option}”的完整说明',
|
||||
'option.collapse': '收起“{option}”的说明',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过',
|
||||
@@ -65,8 +67,10 @@ export const en = {
|
||||
'nav.maximize': 'Expand the question card',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'nav.close': 'Close the panel — reopen it from the tool call',
|
||||
'text.expand': 'More',
|
||||
'text.collapse': 'Show less',
|
||||
'question.expand': 'Show the full question',
|
||||
'question.collapse': 'Show less of the question',
|
||||
'option.expand': 'Show the full description of “{option}”',
|
||||
'option.collapse': 'Show less of “{option}”',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip',
|
||||
|
||||
@@ -315,32 +315,34 @@ describe('QuestionComposer', () => {
|
||||
|
||||
// CSS shows each toggle only while its copy overflows; every candidate is rendered.
|
||||
const heading = screen.getByRole('heading', { name: 'How should the release graph change?' })
|
||||
const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' })
|
||||
expect(rest).toHaveLength(0)
|
||||
expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id)
|
||||
const questionToggle = screen.getByRole('button', { name: '展开完整问题' })
|
||||
const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' })
|
||||
expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle])
|
||||
expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id)
|
||||
const option = screen.getByRole('radio', { name: 'Explicit install' })
|
||||
const text = screen.getByText(description)
|
||||
expect(option.getAttribute('aria-describedby')).toBe(text.id)
|
||||
expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id)
|
||||
expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id)
|
||||
expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id)
|
||||
expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id)
|
||||
expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(descriptionToggle as HTMLElement)
|
||||
expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(descriptionToggle?.textContent).toBe('收起')
|
||||
fireEvent.click(descriptionToggle)
|
||||
expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明')
|
||||
expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true)
|
||||
expect(screen.getByText(description)).toBe(text)
|
||||
expect(option.getAttribute('aria-checked')).toBe('false')
|
||||
expect(questionToggle?.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(questionToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(questionToggle as HTMLElement)
|
||||
expect(questionToggle?.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(questionToggle)
|
||||
expect(questionToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(questionToggle.getAttribute('aria-label')).toBe('收起问题')
|
||||
expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true)
|
||||
fireEvent.click(questionToggle as HTMLElement)
|
||||
fireEvent.click(descriptionToggle as HTMLElement)
|
||||
fireEvent.click(questionToggle)
|
||||
fireEvent.click(descriptionToggle)
|
||||
expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false)
|
||||
expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false)
|
||||
expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle])
|
||||
expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle])
|
||||
})
|
||||
|
||||
it('resets scrolling and expanded copy when the question changes', () => {
|
||||
@@ -361,8 +363,9 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy()
|
||||
const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement
|
||||
firstScroll.scrollTop = 80
|
||||
for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle)
|
||||
expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2)
|
||||
for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle)
|
||||
expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Continue' }))
|
||||
|
||||
const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement
|
||||
@@ -370,11 +373,12 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
|
||||
expect(secondScroll).toBe(firstScroll)
|
||||
expect(secondScroll.scrollTop).toBe(0)
|
||||
expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull()
|
||||
expect(secondScroll.querySelector('[data-expanded]')).toBeNull()
|
||||
expect(screen.queryByText(firstDescription)).toBeNull()
|
||||
const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1]
|
||||
expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
|
||||
const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' })
|
||||
expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
|
||||
})
|
||||
|
||||
it('renders plan detail through the shared assistant Markdown primitive', () => {
|
||||
@@ -948,7 +952,7 @@ describe('review card', () => {
|
||||
expect(chosen.getAttribute('aria-checked')).toBe('true')
|
||||
expect(chosen.disabled).toBe(true)
|
||||
// Long copy stays readable on a frozen card.
|
||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false])
|
||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false])
|
||||
// Nothing is left to send, and the unused free-text field would read as
|
||||
// somewhere to type.
|
||||
expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()
|
||||
|
||||
Reference in new issue
Block a user