mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
11b6119482
commit
b2c188bc46
3 files changed
+44
-10
No files matched your search
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user