From fb290b388d92a73cb9ee189c8c44242bf8ec10fa Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 12:02:02 +0800 Subject: [PATCH] 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. --- apps/web/tests/question-composer.e2e.ts | 16 +++++++++++ .../src/client/QuestionComposer.module.css | 9 ++++--- .../src/client/QuestionComposer.tsx | 27 +++++++++++-------- .../question-composer-styles.client.spec.ts | 6 +++-- .../user-questions-composer.client.spec.tsx | 18 +++++++++++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 0861e3a8ba..1e06d0cbb6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -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) diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index fa75de76e4..5c530aef72 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -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; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 5c00ccbf53..0baf6e54c5 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -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(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({ 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, diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 4774ce8e67..7778302296 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -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') }) }) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 86650560fb..dfa9541320 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -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() + 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',