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:
1 parent
f8a094e465
commit
fb290b388d
5 files changed
+59
-17
No files matched your search
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user