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',