diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts index 0bf0979ee..fe13504f9 100644 --- a/e2e/coach-answer-layout.spec.ts +++ b/e2e/coach-answer-layout.spec.ts @@ -16,8 +16,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * bubble's left edge and no taller than one 44 px target: copy, read * aloud, try again, details, then the time. The model and the tokens * are not text on the row; the details icon shows them in a tooltip on - * keyboard focus (desktop) and on a tap (phone), and a second tap or - * Escape closes it. + * keyboard focus (desktop) and on a tap (phone); Enter and Space keep it + * open, and a second tap or Escape closes it. * 2. The follow-up chips sit in the answer's column: their left edge is * the bubble's, their text is the answer's size, and a chip is at most * 36 px tall beside a pointer and at least 44 px on a phone. @@ -296,8 +296,14 @@ test.describe("Coach answer layout", () => { await expect(tip).toContainText("1,234 tokens"); await expect(info).toHaveAttribute("aria-describedby", /.+/); await shoot(page, testInfo, `answer-info-${viewport.label}`); - if (desktop) await page.keyboard.press("Escape"); - else await info.tap(); + if (desktop) { + // Enter and Space on the focused icon keep it open; Escape closes it. + for (const key of ["Enter", "Space"]) { + await page.keyboard.press(key); + await expect(tip).toBeVisible(); + } + await page.keyboard.press("Escape"); + } else await info.tap(); await expect(tip).toHaveCount(0); // 2. The chips in the answer's column, at the answer's size. diff --git a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx index 45c326508..928dee229 100644 --- a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx @@ -50,7 +50,11 @@ vi.mock("../read-aloud", async (importOriginal) => { }); import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble"; -import { answerInfoLines, messageTimeText } from "../message-actions"; +import { + answerInfoLines, + answerInfoOpenAfterClick, + messageTimeText, +} from "../message-actions"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { return renderToStaticMarkup( @@ -302,3 +306,15 @@ describe("answerInfoLines", () => { ]); }); }); + +describe("answerInfoOpenAfterClick", () => { + it("opens on Enter or Space, never closes, though focus opened it already", () => { + // A keyboard activation fires no pointer-down. + expect(answerInfoOpenAfterClick(null)).toBe(true); + }); + + it("toggles on a tap or a click from the state at pointer-down", () => { + expect(answerInfoOpenAfterClick(false)).toBe(true); + expect(answerInfoOpenAfterClick(true)).toBe(false); + }); +}); diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx index 773e2b5df..5748241af 100644 --- a/src/components/insights/coach-panel/message-actions.tsx +++ b/src/components/insights/coach-panel/message-actions.tsx @@ -180,15 +180,28 @@ function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) { ); } +/** + * Whether the details tooltip is open after a click on its icon, given its + * state when the pointer went down (`null`: no pointer went down, so Enter + * or Space activated the button). A tap or a mouse click toggles from that + * state, before the primitive's own pointer-down close ran. A keyboard + * activation always opens: focus has already opened the tooltip, and a + * toggle would close it on Enter. Escape is the keyboard's way out. + */ +export function answerInfoOpenAfterClick( + openAtPointerDown: boolean | null, +): boolean { + return openAtPointerDown === null ? true : !openAtPointerDown; +} + /** * The model and token count of an answer, behind an info icon. A tooltip, * opened the three ways a reader can reach it: hovering the icon, focusing * it from the keyboard, and tapping it on a touch screen, where there is no * hover. A second tap, a tap elsewhere or Escape closes it. * - * The tap is handled here because the tooltip primitive closes on every - * click: the click toggles from the state the tooltip had when the pointer - * went down, before the primitive's own pointer-down close ran. + * The click is handled here because the tooltip primitive closes on every + * click (`answerInfoOpenAfterClick` decides instead). */ function AnswerInfoButton({ lines }: { lines: string[] }) { const { t } = useTranslations(); @@ -210,9 +223,8 @@ function AnswerInfoButton({ lines }: { lines: string[] }) { onClick={(event) => { // Keep the primitive from closing it again on this click. event.preventDefault(); - const was = openAtPointerDown.current; + setOpen(answerInfoOpenAfterClick(openAtPointerDown.current)); openAtPointerDown.current = null; - setOpen(was === null ? (current) => !current : !was); }} >