fix(ui-user-questions): clip dropped lines from hit testing

A collapsed question or description keeps its full line budget and only
masked its dropped line, so that transparent line still took clicks: it
covered the first line of the question detail and the top edge of the
custom answer row. clip-path now removes the line from hit testing as
well as from view, and Web e2e hit tests pin both targets.

Expansions also record the question they belong to and apply only while
that question is shown, so a position change from another surface of the
same Session no longer carries them to a different question. `.title`
returns to its base margin; `.questionTitle` owns the flex sizing.
This commit is contained in:
yudshj committed 2026-10-21 16:20:04 +08:00
1 parent f8a094e465
commit fb290b388d
5 files changed
+59 -17

No files matched your search

+16
View File
@@ -433,6 +433,15 @@ describe('web e2e: resident question composer round trip', () => {
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: 'expand' })
// A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it.
expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true })
expect(await composer.locator('textarea').evaluate((field) => {
const row = field.closest('[class*="customRow"]')
if (!(row instanceof HTMLElement)) return false
const box = row.getBoundingClientRect()
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2)
return hit !== null && row.contains(hit)
})).toBe(true)
await blueToggle.click()
expect(await blue.getAttribute('aria-checked')).toBe('false')
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
@@ -754,6 +763,13 @@ describe('web e2e: resident question composer round trip', () => {
expect(scrollStates).toContain(false)
expect(scrollStates).toContain(true)
// A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail.
expect(await composer.getByText(detail, { exact: true }).evaluate((text) => {
const box = text.getBoundingClientRect()
const hit = document.elementFromPoint(box.left + 8, box.top + 3)
return hit !== null && text.contains(hit)
})).toBe(true)
// The question expands in place; its choices move down inside the
// scrollport while navigation and submission stay reachable.
const questionToggle = await toggleFor(heading)
@@ -99,8 +99,7 @@
}
.title {
flex-shrink: 0;
margin: 5px 24px 0;
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 500;
@@ -112,7 +111,8 @@
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. */
bottom margin, not a smaller box, gives the dropped line back to the row,
and clip-path removes that line from hit testing as well as from view. */
.questionTitle {
position: relative;
flex-shrink: 0;
@@ -142,6 +142,7 @@
@keyframes question-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
clip-path: inset(0 0 calc(1lh + 2px) 0);
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;
@@ -160,7 +161,6 @@
.cardMinimized .title {
display: -webkit-box;
margin: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -400,6 +400,7 @@
@keyframes description-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
clip-path: inset(0 0 calc(1lh + 2px) 0);
mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh;
mask-repeat: no-repeat;
@@ -27,10 +27,15 @@ const REMOVED_CARD: QuestionCardSnapshot = {
state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true,
}
/** Long copy the user expanded: the question itself or an option description by index. */
type ExpandedCopy = ReadonlySet<'question' | number>
/** Long copy the user expanded on one question: the question itself or an option description by index. */
interface ExpandedCopy {
/** Index of the question the expansions belong to. */
readonly index: number
/** Expanded copy of that question: `'question'` or an option index. */
readonly copies: ReadonlySet<'question' | number>
}
const NOTHING_EXPANDED: ExpandedCopy = new Set()
const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set()
/**
* Split the conventional recommendation suffix without changing the answer value.
@@ -247,24 +252,24 @@ 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
// CSS decides which copy is clipped; only the user's expansions are state.
const [expanded, setExpanded] = useState<ExpandedCopy>(NOTHING_EXPANDED)
// CSS decides which copy is clipped; only the user's expansions are state,
// and they apply only while their question is the one shown.
const [expandedCopy, setExpandedCopy] = useState<ExpandedCopy>({ index, copies: NOTHING_EXPANDED })
const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED
const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand')
const toggleCopy = (copy: 'question' | number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(copy)) next.add(copy)
return next
})
const copies = new Set(expanded)
if (!copies.delete(copy)) copies.add(copy)
setExpandedCopy({ index, copies })
}
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
if (nextIndex !== index) {
// Another question starts at its top with its long copy collapsed.
if (answerSurface.current !== null) answerSurface.current.scrollTop = 0
setExpanded(NOTHING_EXPANDED)
setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED })
}
actions.replace(pending.key, {
index: nextIndex,
@@ -28,7 +28,7 @@ function keyframes(name: string): string[] {
describe('QuestionComposer long-copy layout', () => {
it('reads question overflow against three lines and keeps two once it overflows', () => {
expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy')
expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy']))
expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([
'max-height: calc(3lh + 2px)',
'margin: 0 0 -2px',
@@ -41,6 +41,7 @@ describe('QuestionComposer long-copy layout', () => {
]))
expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([
'margin-bottom: calc(-1lh - 2px)',
'clip-path: inset(0 0 calc(1lh + 2px) 0)',
'mask-size: 100% 1lh, 100% 1lh',
'mask-position: 0 0, 0 1lh',
]))
@@ -69,6 +70,7 @@ describe('QuestionComposer long-copy layout', () => {
]))
expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([
'margin-bottom: calc(-1lh - 2px)',
'clip-path: inset(0 0 calc(1lh + 2px) 0)',
'mask-size: 100% 1lh',
]))
expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([
@@ -104,6 +106,6 @@ describe('QuestionComposer long-copy layout', () => {
'min-height: 0',
'overflow-y: auto',
]))
expect(declarations('.title')).toContain('flex-shrink: 0')
expect(declarations('.questionTitle')).toContain('flex-shrink: 0')
})
})
@@ -380,6 +380,24 @@ describe('QuestionComposer', () => {
expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
})
it('keeps expansions with their question when the shared position moves elsewhere', () => {
const { carrier } = wait([
{ id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] },
{ id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] },
])
render(<QuestionComposer matched={carrier} {...kit} />)
fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement)
expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
// Another surface of the same Session moves the shared question position.
const blank = { selected: [], custom: '', skipped: false }
act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) })
expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
expect(document.querySelector('[data-expanded]')).toBeNull()
})
it('renders plan detail through the shared assistant Markdown primitive', () => {
const { carrier } = wait([{
id: 'plan',