fix(coach): keep the answer details open on Enter and Space

Focus opens the details tooltip, and the click that Enter or Space then
fires toggled it shut again. A click with no pointer-down before it is a
keyboard activation and now always opens; a tap or a mouse click still
toggles, and Escape closes it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 17:52:14 +02:00
1 parent 11b6119482
commit b2c188bc46
3 files changed
+44 -10

No files matched your search

+10 -4
View File
@@ -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.
@@ -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);
});
});
@@ -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);
}}
>
<Info className="size-4" aria-hidden="true" />